Bladeren bron

feat: Progress towards upcoming 2.x release

3.x
wallo 1 jaar geleden
bovenliggende
commit
4068df7494
100 gewijzigde bestanden met toevoegingen van 6158 en 1456 verwijderingen
  1. 11
    5
      app/Casts/MoneyCast.php
  2. 0
    23
      app/Enums/AccountType.php
  3. 51
    0
      app/Enums/Accounting/AccountCategory.php
  4. 58
    0
      app/Enums/Accounting/AccountType.php
  5. 166
    0
      app/Enums/BankAccountSubtype.php
  6. 108
    0
      app/Enums/BankAccountType.php
  7. 27
    0
      app/Events/PlaidSuccess.php
  8. 33
    0
      app/Events/StartTransactionImport.php
  9. 177
    0
      app/Filament/Company/Pages/Accounting/AccountChart.php
  10. 55
    0
      app/Filament/Company/Pages/Service/ConnectedAccount.php
  11. 1
    0
      app/Filament/Company/Pages/Setting/Appearance.php
  12. 6
    6
      app/Filament/Company/Pages/Setting/CompanyDefault.php
  13. 5
    5
      app/Filament/Company/Pages/Setting/Invoice.php
  14. 134
    0
      app/Filament/Company/Resources/Accounting/TransactionResource.php
  15. 12
    0
      app/Filament/Company/Resources/Accounting/TransactionResource/Pages/CreateTransaction.php
  16. 19
    0
      app/Filament/Company/Resources/Accounting/TransactionResource/Pages/EditTransaction.php
  17. 19
    0
      app/Filament/Company/Resources/Accounting/TransactionResource/Pages/ListTransactions.php
  18. 159
    183
      app/Filament/Company/Resources/Banking/AccountResource.php
  19. 6
    11
      app/Filament/Company/Resources/Banking/AccountResource/Pages/CreateAccount.php
  20. 1
    16
      app/Filament/Company/Resources/Banking/AccountResource/Pages/EditAccount.php
  21. 1
    5
      app/Filament/Company/Resources/Core/DepartmentResource/RelationManagers/ChildrenRelationManager.php
  22. 80
    47
      app/Filament/Company/Resources/Setting/CategoryResource.php
  23. 0
    46
      app/Filament/Company/Resources/Setting/CategoryResource/Pages/CreateCategory.php
  24. 0
    53
      app/Filament/Company/Resources/Setting/CategoryResource/Pages/EditCategory.php
  25. 0
    19
      app/Filament/Company/Resources/Setting/CategoryResource/Pages/ListCategories.php
  26. 52
    0
      app/Filament/Company/Resources/Setting/CategoryResource/Pages/ManageCategory.php
  27. 38
    0
      app/Http/Controllers/PlaidController.php
  28. 92
    0
      app/Listeners/ConfigureChartOfAccounts.php
  29. 5
    3
      app/Listeners/ConfigureCompanyNavigation.php
  30. 81
    0
      app/Listeners/CreateConnectedAccount.php
  31. 143
    0
      app/Listeners/HandleTransactionImport.php
  32. 177
    0
      app/Listeners/PopulateAccountFromPlaid.php
  33. 1
    1
      app/Listeners/SyncAssociatedModels.php
  34. 154
    0
      app/Listeners/SyncTransactionsFromPlaid.php
  35. 1
    1
      app/Listeners/SyncWithCompanyDefaults.php
  36. 220
    0
      app/Livewire/Company/Service/ConnectedAccount/ListInstitutions.php
  37. 171
    0
      app/Livewire/UpdatePassword.php
  38. 183
    0
      app/Livewire/UpdateProfileInformation.php
  39. 126
    0
      app/Models/Accounting/Account.php
  40. 53
    0
      app/Models/Accounting/AccountSubtype.php
  41. 75
    0
      app/Models/Accounting/JournalEntry.php
  42. 83
    0
      app/Models/Accounting/Transaction.php
  43. 32
    35
      app/Models/Banking/BankAccount.php
  44. 78
    0
      app/Models/Banking/ConnectedBankAccount.php
  45. 67
    0
      app/Models/Banking/Institution.php
  46. 20
    2
      app/Models/Company.php
  47. 7
    0
      app/Models/Setting/Category.php
  48. 4
    4
      app/Models/Setting/CompanyDefault.php
  49. 5
    1
      app/Models/Setting/CompanyProfile.php
  50. 1
    1
      app/Models/Setting/Currency.php
  51. 2
    0
      app/Models/Setting/Localization.php
  52. 1
    1
      app/Models/User.php
  53. 135
    50
      app/Observers/AccountObserver.php
  54. 80
    0
      app/Observers/BankAccountObserver.php
  55. 1
    1
      app/Providers/AppServiceProvider.php
  56. 1
    1
      app/Providers/AuthServiceProvider.php
  57. 22
    1
      app/Providers/EventServiceProvider.php
  58. 4
    0
      app/Providers/Filament/AdminPanelProvider.php
  59. 4
    0
      app/Providers/Filament/UserPanelProvider.php
  60. 13
    2
      app/Providers/FilamentCompaniesServiceProvider.php
  61. 21
    0
      app/Providers/MacroServiceProvider.php
  62. 47
    68
      app/Services/CompanyDefaultService.php
  63. 293
    0
      app/Services/PlaidService.php
  64. 4
    4
      app/Traits/Blamable.php
  65. 2
    2
      app/Traits/HandlesResourceRecordCreation.php
  66. 2
    2
      app/Traits/HandlesResourceRecordUpdate.php
  67. 83
    0
      app/Utilities/Accounting/AccountCode.php
  68. 4
    2
      app/Utilities/Localization/Timezone.php
  69. 121
    0
      app/Utilities/Plaid/AccountTypeMapper.php
  70. 0
    1
      composer.json
  71. 758
    632
      composer.lock
  72. 424
    0
      config/chart-of-accounts.php
  73. 7
    0
      config/plaid.php
  74. 5
    0
      config/services.php
  75. 30
    0
      database/factories/Accounting/AccountFactory.php
  76. 26
    0
      database/factories/Accounting/AccountSubtypeFactory.php
  77. 24
    0
      database/factories/Accounting/JournalEntryFactory.php
  78. 24
    0
      database/factories/Accounting/TransactionFactory.php
  79. 3
    3
      database/factories/Banking/BankAccountFactory.php
  80. 24
    0
      database/factories/Banking/InstitutionFactory.php
  81. 108
    0
      database/migrations/2023_09_03_100000_create_accounting_tables.php
  82. 0
    57
      database/migrations/2023_09_04_103821_create_accounts_table.php
  83. 2
    0
      database/migrations/2023_09_07_193412_create_categories_table.php
  84. 8
    8
      database/migrations/2023_09_08_040159_create_company_defaults_table.php
  85. 42
    0
      database/migrations/2024_01_01_234943_create_transactions_table.php
  86. 35
    0
      database/migrations/2024_01_11_062614_create_journal_entries_table.php
  87. 501
    130
      package-lock.json
  88. 5
    2
      resources/css/filament/company/tailwind.config.js
  89. 51
    1
      resources/css/filament/company/theme.css
  90. 76
    0
      resources/css/filament/company/tooltip.css
  91. 1
    1
      resources/css/filament/user/tailwind.config.js
  92. 61
    6
      resources/css/filament/user/theme.css
  93. 13
    2
      resources/data/lang/ar.json
  94. 13
    2
      resources/data/lang/de.json
  95. 20
    2
      resources/data/lang/en.json
  96. 20
    2
      resources/data/lang/es.json
  97. 13
    2
      resources/data/lang/fr.json
  98. 13
    2
      resources/data/lang/id.json
  99. 13
    2
      resources/data/lang/it.json
  100. 0
    0
      resources/data/lang/nl.json

+ 11
- 5
app/Casts/MoneyCast.php Bestand weergeven

@@ -13,7 +13,11 @@ class MoneyCast implements CastsAttributes
13 13
     {
14 14
         $currency_code = $model->getAttribute('currency_code');
15 15
 
16
-        return $value ? money($value, $currency_code)->formatSimple() : '';
16
+        if ($value !== null) {
17
+            return money($value, $currency_code)->formatSimple();
18
+        }
19
+
20
+        return '';
17 21
     }
18 22
 
19 23
     /**
@@ -21,16 +25,18 @@ class MoneyCast implements CastsAttributes
21 25
      */
22 26
     public function set(Model $model, string $key, mixed $value, array $attributes): int
23 27
     {
24
-        if (is_int($value)) {
25
-            return $value;
26
-        }
27
-
28 28
         $currency_code = $model->getAttribute('currency_code') ?? CurrencyAccessor::getDefaultCurrency();
29 29
 
30 30
         if (! $currency_code) {
31 31
             throw new UnexpectedValueException('Currency code is not set');
32 32
         }
33 33
 
34
+        if (is_numeric($value)) {
35
+            $value = (string) $value;
36
+        } elseif (! is_string($value)) {
37
+            throw new UnexpectedValueException('Expected string or numeric value for money cast');
38
+        }
39
+
34 40
         return money($value, $currency_code, true)->getAmount();
35 41
     }
36 42
 }

+ 0
- 23
app/Enums/AccountType.php Bestand weergeven

@@ -1,23 +0,0 @@
1
-<?php
2
-
3
-namespace App\Enums;
4
-
5
-use Filament\Support\Contracts\HasLabel;
6
-
7
-enum AccountType: string implements HasLabel
8
-{
9
-    case Checking = 'checking';
10
-    case Savings = 'savings';
11
-    case MoneyMarket = 'money_market';
12
-    case CreditCard = 'credit_card';
13
-    case Merchant = 'merchant';
14
-
15
-    public const DEFAULT = self::Checking->value;
16
-
17
-    public function getLabel(): ?string
18
-    {
19
-        $label = ucwords(str_replace('_', ' ', $this->value));
20
-
21
-        return translate($label);
22
-    }
23
-}

+ 51
- 0
app/Enums/Accounting/AccountCategory.php Bestand weergeven

@@ -0,0 +1,51 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum AccountCategory: string implements HasLabel
8
+{
9
+    case Asset = 'asset';
10
+    case Liability = 'liability';
11
+    case Equity = 'equity';
12
+    case Revenue = 'revenue';
13
+    case Expense = 'expense';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+
20
+    public function getTypes(): array
21
+    {
22
+        return match ($this) {
23
+            self::Asset => [
24
+                AccountType::CurrentAsset,
25
+                AccountType::NonCurrentAsset,
26
+                AccountType::ContraAsset,
27
+            ],
28
+            self::Liability => [
29
+                AccountType::CurrentLiability,
30
+                AccountType::NonCurrentLiability,
31
+                AccountType::ContraLiability,
32
+            ],
33
+            self::Equity => [
34
+                AccountType::Equity,
35
+                AccountType::ContraEquity,
36
+            ],
37
+            self::Revenue => [
38
+                AccountType::OperatingRevenue,
39
+                AccountType::NonOperatingRevenue,
40
+                AccountType::ContraRevenue,
41
+                AccountType::UncategorizedRevenue,
42
+            ],
43
+            self::Expense => [
44
+                AccountType::OperatingExpense,
45
+                AccountType::NonOperatingExpense,
46
+                AccountType::ContraExpense,
47
+                AccountType::UncategorizedExpense,
48
+            ],
49
+        };
50
+    }
51
+}

+ 58
- 0
app/Enums/Accounting/AccountType.php Bestand weergeven

@@ -0,0 +1,58 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum AccountType: string implements HasLabel
8
+{
9
+    case CurrentAsset = 'current_asset';
10
+    case NonCurrentAsset = 'non_current_asset';
11
+    case ContraAsset = 'contra_asset';
12
+    case CurrentLiability = 'current_liability';
13
+    case NonCurrentLiability = 'non_current_liability';
14
+    case ContraLiability = 'contra_liability';
15
+    case Equity = 'equity';
16
+    case ContraEquity = 'contra_equity';
17
+    case OperatingRevenue = 'operating_revenue';
18
+    case NonOperatingRevenue = 'non_operating_revenue';
19
+    case ContraRevenue = 'contra_revenue';
20
+    case UncategorizedRevenue = 'uncategorized_revenue';
21
+    case OperatingExpense = 'operating_expense';
22
+    case NonOperatingExpense = 'non_operating_expense';
23
+    case ContraExpense = 'contra_expense';
24
+    case UncategorizedExpense = 'uncategorized_expense';
25
+
26
+    public function getLabel(): ?string
27
+    {
28
+        return match ($this) {
29
+            self::CurrentAsset => 'Current Asset',
30
+            self::NonCurrentAsset => 'Non-Current Asset',
31
+            self::ContraAsset => 'Contra Asset',
32
+            self::CurrentLiability => 'Current Liability',
33
+            self::NonCurrentLiability => 'Non-Current Liability',
34
+            self::ContraLiability => 'Contra Liability',
35
+            self::Equity => 'Equity',
36
+            self::ContraEquity => 'Contra Equity',
37
+            self::OperatingRevenue => 'Operating Revenue',
38
+            self::NonOperatingRevenue => 'Non-Operating Revenue',
39
+            self::ContraRevenue => 'Contra Revenue',
40
+            self::UncategorizedRevenue => 'Uncategorized Revenue',
41
+            self::OperatingExpense => 'Operating Expense',
42
+            self::NonOperatingExpense => 'Non-Operating Expense',
43
+            self::ContraExpense => 'Contra Expense',
44
+            self::UncategorizedExpense => 'Uncategorized Expense',
45
+        };
46
+    }
47
+
48
+    public function getCategory(): AccountCategory
49
+    {
50
+        return match ($this) {
51
+            self::CurrentAsset, self::NonCurrentAsset, self::ContraAsset => AccountCategory::Asset,
52
+            self::CurrentLiability, self::NonCurrentLiability, self::ContraLiability => AccountCategory::Liability,
53
+            self::Equity, self::ContraEquity => AccountCategory::Equity,
54
+            self::OperatingRevenue, self::NonOperatingRevenue, self::ContraRevenue, self::UncategorizedRevenue => AccountCategory::Revenue,
55
+            self::OperatingExpense, self::NonOperatingExpense, self::ContraExpense, self::UncategorizedExpense => AccountCategory::Expense,
56
+        };
57
+    }
58
+}

+ 166
- 0
app/Enums/BankAccountSubtype.php Bestand weergeven

@@ -0,0 +1,166 @@
1
+<?php
2
+
3
+namespace App\Enums;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum BankAccountSubtype: string implements HasLabel
8
+{
9
+    // Depository Types
10
+
11
+    case Checking = 'checking';
12
+    case Savings = 'savings';
13
+    case HealthSavingsAccountCash = 'cash_hsa';
14
+    case CertificateOfDeposit = 'cd';
15
+    case MoneyMarket = 'money_market';
16
+    case Paypal = 'paypal';
17
+    case Prepaid = 'prepaid';
18
+    case CashManagement = 'cash_management';
19
+    case ElectronicBenefitsTransfer = 'ebt';
20
+
21
+    // Credit Types
22
+    case CreditCard = 'credit_card';
23
+    case PaypalCredit = 'paypal_credit';
24
+
25
+    // Loan Types
26
+    case Auto = 'auto';
27
+    case Business = 'business';
28
+    case Commercial = 'commercial';
29
+    case Construction = 'construction';
30
+    case Consumer = 'consumer';
31
+    case HomeEquity = 'home_equity';
32
+    case Loan = 'loan'; // Generic loan
33
+    case Mortgage = 'mortgage';
34
+    case Overdraft = 'overdraft';
35
+    case LineOfCredit = 'line_of_credit'; // Pre-approved line of credit
36
+    case Student = 'student';
37
+    case Other = 'other';
38
+
39
+    // Investment Types
40
+    case CollegeSavings529 = '529';
41
+    case Retirement401a = '401a';
42
+    case Retirement401K = '401k';
43
+    case Retirement403b = '403b';
44
+    case DeferredCompensation457b = '457b';
45
+    case Brokerage = 'brokerage';
46
+    case CashIndividualSavingsAccount = 'cash_isa';
47
+    case CryptoCurrencyExchange = 'crypto_exchange';
48
+    case EducationSavingsAccount = 'esa';
49
+    case FixedAnnuity = 'fixed_annuity';
50
+    case GuaranteedInvestmentCertificate = 'gic';
51
+    case HealthReimbursementArrangement = 'hra';
52
+    case HealthSavingsAccountNonCash = 'non_cash_hsa';
53
+    case IndividualRetirementAccount = 'ira';
54
+    case IndividualSavingsAccount = 'isa';
55
+    case KeoghPlan = 'keogh';
56
+    case LifeIncomeFund = 'lif';
57
+    case LifeInsuranceAccount = 'life_insurance';
58
+    case LockedInRetirementAccount = 'lira'; // Instead of LIRA
59
+    case LockedInRetirementIncomeFund = 'lrif'; // Instead of LRIF
60
+    case LockedInRetirementSavingsPlan = 'lrsp'; // Instead of LRSP
61
+    case MutualFundAccount = 'mutual_fund'; // Instead of MutualFund
62
+    case CryptoCurrencyWallet = 'non_custodial_wallet'; // Instead of NonCustodialWallet
63
+    case NonTaxableBrokerageAccount = 'non_taxable_brokerage_account'; // Instead of NonTaxableBrokerageAccount
64
+    case AnnuityAccountOther = 'other_annuity'; // Instead of OtherAnnuity
65
+    case InsuranceAccountOther = 'other_insurance'; // Instead of OtherInsurance
66
+    case PensionAccount = 'pension'; // Instead of Pension
67
+    case PrescribedRetirementIncomeFund = 'prif'; // Instead of PRIF
68
+    case ProfitSharingPlanAccount = 'profit_sharing_plan'; // Instead of ProfitSharingPlan
69
+    case QualifyingShareAccount = 'qshr'; // Instead of QSHR
70
+    case RegisteredDisabilitySavingsPlan = 'rdsp'; // Instead of RDSP
71
+    case RegisteredEducationSavingsPlan = 'resp'; // Instead of RESP
72
+    case RetirementAccountOther = 'retirement'; // Instead of Retirement
73
+    case RestrictedLifeIncomeFund = 'rlif'; // Instead of RLIF
74
+    case RothIRA = 'roth'; // Instead of Roth
75
+    case Roth401k = 'roth_401k'; // Instead of RothFourOhOneK
76
+    case RegisteredRetirementIncomeFund = 'rrif'; // Instead of RRIF
77
+    case RegisteredRetirementSavingsPlan = 'rrsp'; // Instead of RRSP
78
+    case SalaryReductionSEPPlan = 'sarsep'; // Instead of SARSEP
79
+    case SimplifiedEmployeePensionIRA = 'sep_ira'; // Instead of SEPIRA
80
+    case SavingsIncentiveMatchPlanForEmployeesIRA = 'simple_ira'; // Instead of SIMPLEIRA
81
+    case SelfInvestedPersonalPension = 'sipp'; // Instead of SIPP
82
+    case StockPlanAccount = 'stock_plan'; // Instead of StockPlan
83
+    case TaxFreeSavingsAccount = 'tfsa'; // Instead of TFSA
84
+    case TrustAccount = 'trust'; // Instead of Trust
85
+    case UniformGiftToMinorsAct = 'ugma'; // Instead of UGMA
86
+    case UniformTransfersToMinorsAct = 'utma'; // Instead of UTMA
87
+    case VariableAnnuityAccount = 'variable_annuity'; // Instead of VariableAnnuity
88
+
89
+    public function getLabel(): ?string
90
+    {
91
+        $label = match ($this) {
92
+            self::Checking => 'Checking',
93
+            self::Savings => 'Savings',
94
+            self::HealthSavingsAccountCash => 'Health Savings Account (Cash)',
95
+            self::CertificateOfDeposit => 'Certificate of Deposit',
96
+            self::MoneyMarket => 'Money Market',
97
+            self::Paypal => 'PayPal',
98
+            self::Prepaid => 'Prepaid',
99
+            self::CashManagement => 'Cash Management',
100
+            self::ElectronicBenefitsTransfer => 'Electronic Benefits Transfer (EBT)',
101
+            self::CreditCard => 'Credit Card',
102
+            self::PaypalCredit => 'PayPal Credit',
103
+            self::Auto => 'Auto',
104
+            self::Business => 'Business',
105
+            self::Commercial => 'Commercial',
106
+            self::Construction => 'Construction',
107
+            self::Consumer => 'Consumer',
108
+            self::HomeEquity => 'Home Equity',
109
+            self::Loan => 'Loan',
110
+            self::Mortgage => 'Mortgage',
111
+            self::Overdraft => 'Overdraft',
112
+            self::LineOfCredit => 'Line of Credit',
113
+            self::Student => 'Student',
114
+            self::Other => 'Other',
115
+            self::CollegeSavings529 => '529 College Savings Plan',
116
+            self::Retirement401a => '401(a)',
117
+            self::Retirement401K => '401(k)',
118
+            self::Retirement403b => '403(b)',
119
+            self::DeferredCompensation457b => '457(b)',
120
+            self::Brokerage => 'Brokerage',
121
+            self::CashIndividualSavingsAccount => 'Cash Individual Savings Account (ISA)',
122
+            self::CryptoCurrencyExchange => 'Crypto Currency Exchange',
123
+            self::EducationSavingsAccount => 'Education Savings Account (ESA)',
124
+            self::FixedAnnuity => 'Fixed Annuity',
125
+            self::GuaranteedInvestmentCertificate => 'Guaranteed Investment Certificate (GIC)',
126
+            self::HealthSavingsAccountNonCash => 'Health Savings Account (Non-Cash)',
127
+            self::IndividualRetirementAccount => 'Individual Retirement Account (IRA)',
128
+            self::IndividualSavingsAccount => 'Individual Savings Account (ISA)',
129
+            self::KeoghPlan => 'Keogh Plan',
130
+            self::LifeIncomeFund => 'Life Income Fund (LIF)',
131
+            self::LifeInsuranceAccount => 'Life Insurance Account',
132
+            self::LockedInRetirementAccount => 'Locked-In Retirement Account (LIRA)',
133
+            self::LockedInRetirementIncomeFund => 'Locked-In Retirement Income Fund (LRIF)',
134
+            self::LockedInRetirementSavingsPlan => 'Locked-In Retirement Savings Plan (LRSP)',
135
+            self::MutualFundAccount => 'Mutual Fund Account',
136
+            self::CryptoCurrencyWallet => 'Non-Custodial Wallet',
137
+            self::NonTaxableBrokerageAccount => 'Non-Taxable Brokerage Account',
138
+            self::AnnuityAccountOther => 'Other Annuity',
139
+            self::InsuranceAccountOther => 'Other Insurance',
140
+            self::PensionAccount => 'Pension',
141
+            self::PrescribedRetirementIncomeFund => 'Prescribed Retirement Income Fund (PRIF)',
142
+            self::ProfitSharingPlanAccount => 'Profit Sharing Plan',
143
+            self::QualifyingShareAccount => 'Qualifying Share Account (QSHR)',
144
+            self::RegisteredDisabilitySavingsPlan => 'Registered Disability Savings Plan (RDSP)',
145
+            self::RegisteredEducationSavingsPlan => 'Registered Education Savings Plan (RESP)',
146
+            self::RetirementAccountOther => 'Retirement',
147
+            self::RestrictedLifeIncomeFund => 'Restricted Life Income Fund (RLIF)',
148
+            self::RothIRA => 'Roth IRA',
149
+            self::Roth401k => 'Roth 401(k)',
150
+            self::RegisteredRetirementIncomeFund => 'Registered Retirement Income Fund (RRIF)',
151
+            self::RegisteredRetirementSavingsPlan => 'Registered Retirement Savings Plan (RRSP)',
152
+            self::SalaryReductionSEPPlan => 'Salary Reduction SEP Plan (SARSEP)',
153
+            self::SimplifiedEmployeePensionIRA => 'Simplified Employee Pension IRA (SEP IRA)',
154
+            self::SavingsIncentiveMatchPlanForEmployeesIRA => 'Savings Incentive Match Plan for Employees IRA (SIMPLE IRA)',
155
+            self::SelfInvestedPersonalPension => 'Self-Invested Personal Pension (SIPP)',
156
+            self::StockPlanAccount => 'Stock Plan',
157
+            self::TaxFreeSavingsAccount => 'Tax-Free Savings Account (TFSA)',
158
+            self::TrustAccount => 'Trust',
159
+            self::UniformGiftToMinorsAct => 'Uniform Gift to Minors Act (UGMA)',
160
+            self::UniformTransfersToMinorsAct => 'Uniform Transfers to Minors Act (UTMA)',
161
+            self::VariableAnnuityAccount => 'Variable Annuity',
162
+        };
163
+
164
+        return $label;
165
+    }
166
+}

+ 108
- 0
app/Enums/BankAccountType.php Bestand weergeven

@@ -0,0 +1,108 @@
1
+<?php
2
+
3
+namespace App\Enums;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum BankAccountType: string implements HasLabel
8
+{
9
+    case Investment = 'investment';
10
+    case Credit = 'credit';
11
+    case Depository = 'depository';
12
+    case Loan = 'loan';
13
+    case Other = 'other';
14
+
15
+    public const DEFAULT = self::Depository;
16
+
17
+    public function getLabel(): ?string
18
+    {
19
+        return translate($this->name);
20
+    }
21
+
22
+    public function getSubtypes(): array
23
+    {
24
+        return match ($this) {
25
+            self::Depository => [
26
+                BankAccountSubtype::Checking,
27
+                BankAccountSubtype::Savings,
28
+                BankAccountSubtype::HealthSavingsAccountCash,
29
+                BankAccountSubtype::CertificateOfDeposit,
30
+                BankAccountSubtype::MoneyMarket,
31
+                BankAccountSubtype::Paypal,
32
+                BankAccountSubtype::Prepaid,
33
+                BankAccountSubtype::CashManagement,
34
+                BankAccountSubtype::ElectronicBenefitsTransfer,
35
+            ],
36
+            self::Credit => [
37
+                BankAccountSubtype::CreditCard,
38
+                BankAccountSubtype::PaypalCredit,
39
+            ],
40
+            self::Loan => [
41
+                BankAccountSubtype::Auto,
42
+                BankAccountSubtype::Business,
43
+                BankAccountSubtype::Commercial,
44
+                BankAccountSubtype::Construction,
45
+                BankAccountSubtype::Consumer,
46
+                BankAccountSubtype::HomeEquity,
47
+                BankAccountSubtype::Loan,
48
+                BankAccountSubtype::Mortgage,
49
+                BankAccountSubtype::Overdraft,
50
+                BankAccountSubtype::LineOfCredit,
51
+                BankAccountSubtype::Student,
52
+                BankAccountSubtype::Other,
53
+            ],
54
+            self::Investment => [
55
+                BankAccountSubtype::CollegeSavings529,
56
+                BankAccountSubtype::Retirement401a,
57
+                BankAccountSubtype::Retirement401K,
58
+                BankAccountSubtype::Retirement403b,
59
+                BankAccountSubtype::DeferredCompensation457b,
60
+                BankAccountSubtype::Brokerage,
61
+                BankAccountSubtype::CashIndividualSavingsAccount,
62
+                BankAccountSubtype::CryptoCurrencyExchange,
63
+                BankAccountSubtype::EducationSavingsAccount,
64
+                BankAccountSubtype::FixedAnnuity,
65
+                BankAccountSubtype::GuaranteedInvestmentCertificate,
66
+                BankAccountSubtype::HealthSavingsAccountNonCash,
67
+                BankAccountSubtype::IndividualRetirementAccount,
68
+                BankAccountSubtype::IndividualSavingsAccount,
69
+                BankAccountSubtype::KeoghPlan,
70
+                BankAccountSubtype::LifeIncomeFund,
71
+                BankAccountSubtype::LifeInsuranceAccount,
72
+                BankAccountSubtype::LockedInRetirementAccount,
73
+                BankAccountSubtype::LockedInRetirementIncomeFund,
74
+                BankAccountSubtype::LockedInRetirementSavingsPlan,
75
+                BankAccountSubtype::MutualFundAccount,
76
+                BankAccountSubtype::CryptoCurrencyWallet,
77
+                BankAccountSubtype::NonTaxableBrokerageAccount,
78
+                BankAccountSubtype::AnnuityAccountOther,
79
+                BankAccountSubtype::InsuranceAccountOther,
80
+                BankAccountSubtype::PensionAccount,
81
+                BankAccountSubtype::PrescribedRetirementIncomeFund,
82
+                BankAccountSubtype::ProfitSharingPlanAccount,
83
+                BankAccountSubtype::QualifyingShareAccount,
84
+                BankAccountSubtype::RegisteredDisabilitySavingsPlan,
85
+                BankAccountSubtype::RegisteredEducationSavingsPlan,
86
+                BankAccountSubtype::RetirementAccountOther,
87
+                BankAccountSubtype::RestrictedLifeIncomeFund,
88
+                BankAccountSubtype::RothIRA,
89
+                BankAccountSubtype::Roth401k,
90
+                BankAccountSubtype::RegisteredRetirementIncomeFund,
91
+                BankAccountSubtype::RegisteredRetirementSavingsPlan,
92
+                BankAccountSubtype::SalaryReductionSEPPlan,
93
+                BankAccountSubtype::SimplifiedEmployeePensionIRA,
94
+                BankAccountSubtype::SavingsIncentiveMatchPlanForEmployeesIRA,
95
+                BankAccountSubtype::SelfInvestedPersonalPension,
96
+                BankAccountSubtype::StockPlanAccount,
97
+                BankAccountSubtype::TaxFreeSavingsAccount,
98
+                BankAccountSubtype::TrustAccount,
99
+                BankAccountSubtype::UniformGiftToMinorsAct,
100
+                BankAccountSubtype::UniformTransfersToMinorsAct,
101
+                BankAccountSubtype::VariableAnnuityAccount,
102
+            ],
103
+            self::Other => [
104
+                BankAccountSubtype::Other,
105
+            ],
106
+        };
107
+    }
108
+}

+ 27
- 0
app/Events/PlaidSuccess.php Bestand weergeven

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\Events;
4
+
5
+use App\Models\Company;
6
+use Illuminate\Foundation\Events\Dispatchable;
7
+use Illuminate\Queue\SerializesModels;
8
+
9
+class PlaidSuccess
10
+{
11
+    use Dispatchable, SerializesModels;
12
+
13
+    public string $publicToken;
14
+    public string $accessToken;
15
+
16
+    public Company $company;
17
+
18
+    /**
19
+     * Create a new event instance.
20
+     */
21
+    public function __construct(string $publicToken, string $accessToken, Company $company)
22
+    {
23
+        $this->publicToken = $publicToken;
24
+        $this->accessToken = $accessToken;
25
+        $this->company = $company;
26
+    }
27
+}

+ 33
- 0
app/Events/StartTransactionImport.php Bestand weergeven

@@ -0,0 +1,33 @@
1
+<?php
2
+
3
+namespace App\Events;
4
+
5
+use App\Models\Company;
6
+use Illuminate\Broadcasting\Channel;
7
+use Illuminate\Broadcasting\InteractsWithSockets;
8
+use Illuminate\Broadcasting\PresenceChannel;
9
+use Illuminate\Broadcasting\PrivateChannel;
10
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
11
+use Illuminate\Foundation\Events\Dispatchable;
12
+use Illuminate\Queue\SerializesModels;
13
+
14
+class StartTransactionImport
15
+{
16
+    use Dispatchable, SerializesModels;
17
+
18
+    public Company $company;
19
+    public $connectedBankAccountId;
20
+    public $selectedBankAccountId;
21
+    public $startDate;
22
+
23
+    /**
24
+     * Create a new event instance.
25
+     */
26
+    public function __construct($company, $connectedBankAccountId, $selectedBankAccountId, $startDate)
27
+    {
28
+        $this->company = $company;
29
+        $this->connectedBankAccountId = $connectedBankAccountId;
30
+        $this->selectedBankAccountId = $selectedBankAccountId;
31
+        $this->startDate = $startDate;
32
+    }
33
+}

+ 177
- 0
app/Filament/Company/Pages/Accounting/AccountChart.php Bestand weergeven

@@ -0,0 +1,177 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Accounting;
4
+
5
+use App\Models\Accounting\Account as ChartModel;
6
+use App\Enums\Accounting\AccountCategory;
7
+use App\Models\Accounting\AccountSubtype;
8
+use App\Utilities\Accounting\AccountCode;
9
+use App\Utilities\Currency\CurrencyAccessor;
10
+use Filament\Actions\Action;
11
+use Filament\Actions\CreateAction;
12
+use Filament\Actions\EditAction;
13
+use Filament\Forms\Components\Select;
14
+use Filament\Forms\Components\Textarea;
15
+use Filament\Forms\Components\TextInput;
16
+use Filament\Forms\Form;
17
+use Filament\Forms\Get;
18
+use Filament\Forms\Set;
19
+use Filament\Pages\Page;
20
+use Filament\Support\Enums\MaxWidth;
21
+use Illuminate\Support\Collection;
22
+use Livewire\Attributes\Computed;
23
+use Livewire\Attributes\Url;
24
+
25
+class AccountChart extends Page
26
+{
27
+    protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
28
+
29
+    protected static ?string $title = 'Chart of Accounts';
30
+
31
+    protected static ?string $navigationGroup = 'Accounting';
32
+
33
+    protected static ?string $slug = 'accounting/chart';
34
+
35
+    protected static string $view = 'filament.company.pages.accounting.chart';
36
+
37
+    public ?ChartModel $chart = null;
38
+
39
+    #[Url]
40
+    public ?string $activeTab = null;
41
+
42
+    public function mount(): void
43
+    {
44
+        $this->activeTab = $this->activeTab ?? AccountCategory::Asset->value;
45
+    }
46
+
47
+    protected function configureAction(Action $action): void
48
+    {
49
+        $action
50
+            ->modalWidth(MaxWidth::TwoExtraLarge)
51
+            ->stickyModalHeader()
52
+            ->stickyModalFooter();
53
+    }
54
+
55
+    #[Computed]
56
+    public function categories(): Collection
57
+    {
58
+        return AccountSubtype::withCount('accounts')
59
+            ->get()
60
+            ->groupBy('category');
61
+    }
62
+
63
+    public function editChartAction(): Action
64
+    {
65
+        return EditAction::make()
66
+            ->iconButton()
67
+            ->record($this->chart)
68
+            ->name('editChart')
69
+            ->label('Edit account')
70
+            ->modalHeading('Edit Account')
71
+            ->icon('heroicon-m-pencil-square')
72
+            ->mountUsing(function (array $arguments, Form $form) {
73
+                $chartId = $arguments['chart'];
74
+                $this->chart = ChartModel::find($chartId);
75
+
76
+                $form
77
+                    ->fill($this->chart->toArray())
78
+                    ->operation('edit')
79
+                    ->model($this->chart); // This is needed for form relationships to work (maybe a bug in Filament regarding passed arguments related to timing)
80
+            })
81
+            ->form($this->getChartForm());
82
+    }
83
+
84
+    public function createChartAction(): Action
85
+    {
86
+        return CreateAction::make()
87
+            ->link()
88
+            ->name('createChart')
89
+            ->form($this->getChartForm())
90
+            ->model(ChartModel::class)
91
+            ->label('Add a new account')
92
+            ->icon('heroicon-o-plus-circle')
93
+            ->mountUsing(function (array $arguments, Form $form) {
94
+                $subtypeId = $arguments['subtype'];
95
+                $this->chart = new ChartModel([
96
+                    'subtype_id' => $subtypeId,
97
+                ]);
98
+
99
+                if ($subtypeId) {
100
+                    $companyId = auth()->user()->currentCompany->id;
101
+                    $generatedCode = AccountCode::generate($companyId, $subtypeId);
102
+                    $this->chart->code = $generatedCode;
103
+                }
104
+
105
+                $form->fill($this->chart->toArray())
106
+                    ->operation('create');
107
+            });
108
+    }
109
+
110
+    private function getChartForm(bool $useActiveTab = true): array
111
+    {
112
+        return [
113
+            Select::make('subtype_id')
114
+                ->label('Type')
115
+                ->required()
116
+                ->live()
117
+                ->disabled(static fn (string $operation, ?ChartModel $record) => $operation === 'edit' && $record?->default === true)
118
+                ->options($this->getChartSubtypeOptions($useActiveTab))
119
+                ->afterStateUpdated(static function (?string $state, Set $set): void {
120
+                   if ($state) {
121
+                       $companyId = auth()->user()->currentCompany->id;
122
+                       $generatedCode = AccountCode::generate($companyId, $state);
123
+                       $set('code', $generatedCode);
124
+                   }
125
+                }),
126
+            TextInput::make('code')
127
+                ->label('Code')
128
+                ->required()
129
+                ->validationAttribute('account code')
130
+                ->unique(table: ChartModel::class, column: 'code', ignoreRecord: true)
131
+                ->validateAccountCode(static fn (Get $get) => $get('subtype_id')),
132
+            TextInput::make('name')
133
+                ->label('Name')
134
+                ->required(),
135
+            Select::make('currency_code')
136
+                ->localizeLabel('Currency')
137
+                ->relationship('currency', 'name')
138
+                ->default(CurrencyAccessor::getDefaultCurrency())
139
+                ->preload()
140
+                ->searchable()
141
+                ->visible(function (Get $get): bool {
142
+                    return filled($get('subtype_id')) && AccountSubtype::find($get('subtype_id'))->multi_currency;
143
+                })
144
+                ->live(),
145
+            Textarea::make('description')
146
+                ->label('Description')
147
+                ->autosize(),
148
+        ];
149
+    }
150
+
151
+    private function getChartSubtypeOptions($useActiveTab = true): array
152
+    {
153
+        $subtypes = $useActiveTab ?
154
+            AccountSubtype::where('category', $this->activeTab)->get() :
155
+            AccountSubtype::all();
156
+
157
+        return $subtypes->groupBy(fn(AccountSubtype $subtype) => $subtype->type->getLabel())
158
+            ->map(fn(Collection $subtypes, string $type) => $subtypes->mapWithKeys(static fn (AccountSubtype $subtype) => [$subtype->id => $subtype->name]))
159
+            ->toArray();
160
+    }
161
+
162
+    protected function getHeaderActions(): array
163
+    {
164
+        return [
165
+            CreateAction::make()
166
+                ->button()
167
+                ->label('Add New Account')
168
+                ->model(ChartModel::class)
169
+                ->form($this->getChartForm(false)),
170
+        ];
171
+    }
172
+
173
+    public function getCategoryLabel($categoryValue): string
174
+    {
175
+        return AccountCategory::from($categoryValue)->getLabel();
176
+    }
177
+}

+ 55
- 0
app/Filament/Company/Pages/Service/ConnectedAccount.php Bestand weergeven

@@ -0,0 +1,55 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Service;
4
+
5
+use Filament\Actions\Action;
6
+use Filament\Facades\Filament;
7
+use Filament\Pages\Page;
8
+use Filament\Support\Enums\MaxWidth;
9
+use Illuminate\Contracts\Support\Htmlable;
10
+
11
+class ConnectedAccount extends Page
12
+{
13
+    protected static ?string $navigationIcon = 'heroicon-o-building-library';
14
+
15
+    protected static ?string $title = 'Connected Accounts';
16
+
17
+    protected static ?string $navigationGroup = 'Services';
18
+
19
+    protected static ?string $slug = 'services/connected-accounts';
20
+
21
+    protected static string $view = 'filament.company.pages.service.connected-account';
22
+
23
+    public function getTitle(): string|Htmlable
24
+    {
25
+        return translate(static::$title);
26
+    }
27
+
28
+    public static function getNavigationLabel(): string
29
+    {
30
+        return translate(static::$title);
31
+    }
32
+
33
+    public static function getNavigationParentItem(): ?string
34
+    {
35
+        if (Filament::hasTopNavigation()) {
36
+            return translate('Banking');
37
+        }
38
+
39
+        return null;
40
+    }
41
+
42
+    protected function getHeaderActions(): array
43
+    {
44
+        return [
45
+            Action::make('connect')
46
+                ->label('Connect Account')
47
+                ->dispatch('createToken'),
48
+        ];
49
+    }
50
+
51
+    public function getMaxContentWidth(): MaxWidth|string|null
52
+    {
53
+        return MaxWidth::ScreenLarge;
54
+    }
55
+}

+ 1
- 0
app/Filament/Company/Pages/Setting/Appearance.php Bestand weergeven

@@ -131,6 +131,7 @@ class Appearance extends Page
131 131
                     ->localizeLabel()
132 132
                     ->options(
133 133
                         collect(PrimaryColor::cases())
134
+                            ->sort(static fn ($a, $b) => $a->value <=> $b->value)
134 135
                             ->mapWithKeys(static fn ($case) => [
135 136
                                 $case->value => "<span class='flex items-center gap-x-4'>
136 137
                                 <span class='rounded-full w-4 h-4' style='background:rgb(" . $case->getColor()[600] . ")'></span>

+ 6
- 6
app/Filament/Company/Pages/Setting/CompanyDefault.php Bestand weergeven

@@ -3,7 +3,7 @@
3 3
 namespace App\Filament\Company\Pages\Setting;
4 4
 
5 5
 use App\Events\CompanyDefaultUpdated;
6
-use App\Models\Banking\Account;
6
+use App\Models\Banking\BankAccount;
7 7
 use App\Models\Setting\CompanyDefault as CompanyDefaultModel;
8 8
 use App\Models\Setting\Currency;
9 9
 use App\Models\Setting\Discount;
@@ -127,12 +127,12 @@ class CompanyDefault extends Page
127 127
     {
128 128
         return Section::make('General')
129 129
             ->schema([
130
-                Select::make('account_id')
130
+                Select::make('bank_account_id')
131 131
                     ->localizeLabel()
132
-                    ->relationship('account', 'name')
133
-                    ->getOptionLabelFromRecordUsing(function (Account $record) {
134
-                        $name = $record->name;
135
-                        $currency = $this->renderBadgeOptionLabel($record->currency_code);
132
+                    ->relationship('bankAccount', 'name')
133
+                    ->getOptionLabelFromRecordUsing(function (BankAccount $record) {
134
+                        $name = $record->account->name;
135
+                        $currency = $this->renderBadgeOptionLabel($record->account->currency_code);
136 136
 
137 137
                         return "{$name} ⁓ {$currency}";
138 138
                     })

+ 5
- 5
app/Filament/Company/Pages/Setting/Invoice.php Bestand weergeven

@@ -15,6 +15,7 @@ use Filament\Forms\Components\ColorPicker;
15 15
 use Filament\Forms\Components\Component;
16 16
 use Filament\Forms\Components\FileUpload;
17 17
 use Filament\Forms\Components\Grid;
18
+use Filament\Forms\Components\MarkdownEditor;
18 19
 use Filament\Forms\Components\Section;
19 20
 use Filament\Forms\Components\Select;
20 21
 use Filament\Forms\Components\Textarea;
@@ -169,8 +170,7 @@ class Invoice extends Page
169 170
                 TextInput::make('subheader')
170 171
                     ->localizeLabel()
171 172
                     ->nullable(),
172
-                Textarea::make('terms')
173
-                    ->localizeLabel()
173
+                MarkdownEditor::make('terms')
174 174
                     ->nullable(),
175 175
                 Textarea::make('footer')
176 176
                     ->localizeLabel('Footer / Notes')
@@ -303,17 +303,17 @@ class Invoice extends Page
303 303
                         ViewField::make('preview.default')
304 304
                             ->columnSpan(2)
305 305
                             ->hiddenLabel()
306
-                            ->visible(static fn (callable $get) => $get('template') === 'default')
306
+                            ->visible(static fn (Get $get) => $get('template') === 'default')
307 307
                             ->view('filament.company.components.invoice-layouts.default'),
308 308
                         ViewField::make('preview.modern')
309 309
                             ->columnSpan(2)
310 310
                             ->hiddenLabel()
311
-                            ->visible(static fn (callable $get) => $get('template') === 'modern')
311
+                            ->visible(static fn (Get $get) => $get('template') === 'modern')
312 312
                             ->view('filament.company.components.invoice-layouts.modern'),
313 313
                         ViewField::make('preview.classic')
314 314
                             ->columnSpan(2)
315 315
                             ->hiddenLabel()
316
-                            ->visible(static fn (callable $get) => $get('template') === 'classic')
316
+                            ->visible(static fn (Get $get) => $get('template') === 'classic')
317 317
                             ->view('filament.company.components.invoice-layouts.classic'),
318 318
                     ])->columnSpan(2),
319 319
             ])->columns(3);

+ 134
- 0
app/Filament/Company/Resources/Accounting/TransactionResource.php Bestand weergeven

@@ -0,0 +1,134 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting;
4
+
5
+use App\Enums\DateFormat;
6
+use App\Filament\Company\Resources\Accounting\TransactionResource\Pages;
7
+use App\Filament\Company\Resources\Accounting\TransactionResource\RelationManagers;
8
+use App\Models\Accounting\Transaction;
9
+use App\Models\Banking\Account;
10
+use App\Models\Setting\Localization;
11
+use Filament\Forms;
12
+use Filament\Forms\Form;
13
+use Filament\Resources\Resource;
14
+use Filament\Support\Enums\FontWeight;
15
+use Filament\Tables;
16
+use Filament\Tables\Table;
17
+use Illuminate\Database\Eloquent\Builder;
18
+use Illuminate\Database\Eloquent\SoftDeletingScope;
19
+use Illuminate\Support\Carbon;
20
+
21
+class TransactionResource extends Resource
22
+{
23
+    protected static ?string $model = Transaction::class;
24
+
25
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
26
+
27
+    public static function form(Form $form): Form
28
+    {
29
+        return $form
30
+            ->schema([
31
+                Forms\Components\DatePicker::make('posted_at')
32
+                    ->label('Posted At')
33
+                    ->required()
34
+                    ->default(now()),
35
+                Forms\Components\TextInput::make('description')
36
+                    ->label('Description'),
37
+                Forms\Components\Select::make('type')
38
+                    ->label('Type')
39
+                    ->options([
40
+                        'expense' => 'Expense',
41
+                        'income' => 'Income',
42
+                        'transfer' => 'Transfer',
43
+                    ])
44
+                    ->required(),
45
+                Forms\Components\Select::make('method')
46
+                    ->label('Method')
47
+                    ->options([
48
+                        'deposit' => 'Deposit',
49
+                        'withdrawal' => 'Withdrawal',
50
+                    ])
51
+                    ->required(),
52
+                Forms\Components\TextInput::make('amount')
53
+                    ->label('Amount')
54
+                    ->money(static function (Forms\Get $get) {
55
+                        $account = $get('account_id');
56
+
57
+                        if ($account) {
58
+                            $account = Account::find($account);
59
+
60
+                            if ($account) {
61
+                                return $account->currency_code;
62
+                            }
63
+                        }
64
+
65
+                        return 'USD';
66
+                    })
67
+                    ->required(),
68
+                Forms\Components\Select::make('category_id')
69
+                    ->label('Category')
70
+                    ->relationship('category', 'name')
71
+                    ->searchable()
72
+                    ->preload()
73
+                    ->required(),
74
+            ]);
75
+    }
76
+
77
+    public static function table(Table $table): Table
78
+    {
79
+        return $table
80
+            ->columns([
81
+                Tables\Columns\TextColumn::make('posted_at')
82
+                    ->label('Date')
83
+                    ->formatStateUsing(static function ($state) {
84
+                        $dateFormat = Localization::firstOrFail()->date_format->value ?? DateFormat::DEFAULT;
85
+
86
+                        return Carbon::parse($state)->translatedFormat($dateFormat);
87
+                    }),
88
+                Tables\Columns\TextColumn::make('description')
89
+                    ->wrap()
90
+                    ->label('Description'),
91
+                Tables\Columns\TextColumn::make('category.name')
92
+                    ->label('Category')
93
+                    ->html()
94
+                    ->formatStateUsing(function ($state, Transaction $record) {
95
+                        $color = $record->category->color ?? '#000000';
96
+
97
+                        return "<span style='display: inline-block; width: 8px; height: 8px; background-color: {$color}; border-radius: 50%; margin-right: 3px;'></span> {$state}";
98
+                    }),
99
+                Tables\Columns\TextColumn::make('amount')
100
+                    ->label('Amount')
101
+                    ->sortable()
102
+                    ->weight(FontWeight::Medium)
103
+                    ->color(static fn (Transaction $record) => $record->type === 'expense' ? 'danger' : null)
104
+                    ->currency(static fn (Transaction $record) => $record->account->currency_code, true),
105
+            ])
106
+            ->filters([
107
+                //
108
+            ])
109
+            ->actions([
110
+                Tables\Actions\EditAction::make(),
111
+            ])
112
+            ->bulkActions([
113
+                Tables\Actions\BulkActionGroup::make([
114
+                    Tables\Actions\DeleteBulkAction::make(),
115
+                ]),
116
+            ]);
117
+    }
118
+
119
+    public static function getRelations(): array
120
+    {
121
+        return [
122
+            //
123
+        ];
124
+    }
125
+
126
+    public static function getPages(): array
127
+    {
128
+        return [
129
+            'index' => Pages\ListTransactions::route('/'),
130
+            'create' => Pages\CreateTransaction::route('/create'),
131
+            'edit' => Pages\EditTransaction::route('/{record}/edit'),
132
+        ];
133
+    }
134
+}

+ 12
- 0
app/Filament/Company/Resources/Accounting/TransactionResource/Pages/CreateTransaction.php Bestand weergeven

@@ -0,0 +1,12 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting\TransactionResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Accounting\TransactionResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\CreateRecord;
8
+
9
+class CreateTransaction extends CreateRecord
10
+{
11
+    protected static string $resource = TransactionResource::class;
12
+}

+ 19
- 0
app/Filament/Company/Resources/Accounting/TransactionResource/Pages/EditTransaction.php Bestand weergeven

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting\TransactionResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Accounting\TransactionResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\EditRecord;
8
+
9
+class EditTransaction extends EditRecord
10
+{
11
+    protected static string $resource = TransactionResource::class;
12
+
13
+    protected function getHeaderActions(): array
14
+    {
15
+        return [
16
+            Actions\DeleteAction::make(),
17
+        ];
18
+    }
19
+}

+ 19
- 0
app/Filament/Company/Resources/Accounting/TransactionResource/Pages/ListTransactions.php Bestand weergeven

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Accounting\TransactionResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Accounting\TransactionResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\ListRecords;
8
+
9
+class ListTransactions extends ListRecords
10
+{
11
+    protected static string $resource = TransactionResource::class;
12
+
13
+    protected function getHeaderActions(): array
14
+    {
15
+        return [
16
+            Actions\CreateAction::make(),
17
+        ];
18
+    }
19
+}

+ 159
- 183
app/Filament/Company/Resources/Banking/AccountResource.php Bestand weergeven

@@ -3,12 +3,16 @@
3 3
 namespace App\Filament\Company\Resources\Banking;
4 4
 
5 5
 use App\Actions\OptionAction\CreateCurrency;
6
-use App\Enums\AccountType;
6
+use App\Enums\Accounting\AccountCategory;
7
+use App\Enums\Accounting\AccountType;
8
+use App\Enums\BankAccountType;
7 9
 use App\Facades\Forex;
8 10
 use App\Filament\Company\Resources\Banking\AccountResource\Pages;
9
-use App\Models\Banking\Account;
11
+use App\Models\Accounting\AccountSubtype;
12
+use App\Models\Banking\BankAccount;
10 13
 use App\Utilities\Currency\CurrencyAccessor;
11 14
 use App\Utilities\Currency\CurrencyConverter;
15
+use BackedEnum;
12 16
 use Filament\Facades\Filament;
13 17
 use Filament\Forms;
14 18
 use Filament\Forms\Form;
@@ -17,14 +21,16 @@ use Filament\Resources\Resource;
17 21
 use Filament\Support\Enums\FontWeight;
18 22
 use Filament\Tables;
19 23
 use Filament\Tables\Table;
24
+use Illuminate\Support\Collection;
20 25
 use Illuminate\Support\Facades\Auth;
21 26
 use Illuminate\Support\Facades\DB;
22 27
 use Illuminate\Validation\Rules\Unique;
28
+use Livewire\Component as Livewire;
23 29
 use Wallo\FilamentSelectify\Components\ToggleButton;
24 30
 
25 31
 class AccountResource extends Resource
26 32
 {
27
-    protected static ?string $model = Account::class;
33
+    protected static ?string $model = BankAccount::class;
28 34
 
29 35
     protected static ?string $modelLabel = 'Account';
30 36
 
@@ -52,200 +58,152 @@ class AccountResource extends Resource
52 58
     {
53 59
         return $form
54 60
             ->schema([
55
-                Forms\Components\Group::make()
61
+                Forms\Components\Section::make('Account Information')
56 62
                     ->schema([
57
-                        Forms\Components\Section::make('Account Information')
63
+                        Forms\Components\Select::make('type')
64
+                            ->options(BankAccountType::class)
65
+                            ->localizeLabel()
66
+                            ->searchable()
67
+                            ->default(BankAccountType::DEFAULT)
68
+                            ->live()
69
+                            ->afterStateUpdated(static function (Forms\Set $set, $state, ?BankAccount $record, string $operation) {
70
+                                if ($operation === 'create') {
71
+                                    $set('account.subtype_id', null);
72
+                                } elseif ($operation === 'edit' && $record !== null) {
73
+                                    if ($state !== $record->type->value) {
74
+                                        $set('account.subtype_id', null);
75
+                                    } else {
76
+                                        $set('account.subtype_id', $record->account->subtype_id);
77
+                                    }
78
+                                }
79
+                            })
80
+                            ->required(),
81
+                        Forms\Components\Group::make()
82
+                            ->relationship('account')
58 83
                             ->schema([
59
-                                Forms\Components\Select::make('type')
60
-                                    ->options(AccountType::class)
84
+                                Forms\Components\Select::make('subtype_id')
85
+                                    ->options(static function (Forms\Get $get) {
86
+                                        $typeValue = $get('data.type', true); // Bug: $get('type') returns string on edit, but returns Enum type on create
87
+                                        $typeString = $typeValue instanceof BackedEnum ? $typeValue->value : $typeValue;
88
+
89
+                                        return static::groupSubtypesBySubtypeType($typeString);
90
+                                    })
61 91
                                     ->localizeLabel()
62 92
                                     ->searchable()
63
-                                    ->default(AccountType::DEFAULT)
64 93
                                     ->live()
65 94
                                     ->required(),
95
+                            ]),
96
+                        Forms\Components\Group::make()
97
+                            ->relationship('account')
98
+                            ->schema([
66 99
                                 Forms\Components\TextInput::make('name')
67 100
                                     ->maxLength(100)
68 101
                                     ->localizeLabel()
69 102
                                     ->required(),
70
-                                Forms\Components\TextInput::make('number')
71
-                                    ->localizeLabel('Account Number')
72
-                                    ->unique(ignoreRecord: true, modifyRuleUsing: static function (Unique $rule, $state) {
73
-                                        $companyId = Auth::user()->currentCompany->id;
103
+                            ]),
104
+                        Forms\Components\TextInput::make('number')
105
+                            ->localizeLabel('Account Number')
106
+                            ->unique(ignoreRecord: true, modifyRuleUsing: static function (Unique $rule, $state) {
107
+                                $companyId = Auth::user()->currentCompany->id;
74 108
 
75
-                                        return $rule->where('company_id', $companyId)->where('number', $state);
76
-                                    })
77
-                                    ->maxLength(20)
78
-                                    ->validationAttribute('account number')
79
-                                    ->required(),
80
-                                ToggleButton::make('enabled')
81
-                                    ->localizeLabel('Default')
82
-                                    ->onLabel(translate('Yes'))
83
-                                    ->offLabel(translate('No'))
84
-                                    ->hidden(static fn (Forms\Get $get) => $get('type') === AccountType::CreditCard->value),
85
-                            ])->columns(),
86
-                        Forms\Components\Section::make('Currency & Balance')
87
-                            ->schema([
88
-                                Forms\Components\Select::make('currency_code')
89
-                                    ->localizeLabel('Currency')
90
-                                    ->relationship('currency', 'name')
91
-                                    ->default(CurrencyAccessor::getDefaultCurrency())
92
-                                    ->saveRelationshipsUsing(null)
93
-                                    ->disabledOn('edit')
94
-                                    ->dehydrated()
95
-                                    ->preload()
109
+                                return $rule->where('company_id', $companyId)->where('number', $state);
110
+                            })
111
+                            ->maxLength(20)
112
+                            ->validationAttribute('account number')
113
+                            ->required(),
114
+                        ToggleButton::make('enabled')
115
+                            ->localizeLabel('Default'),
116
+                    ])->columns(),
117
+                Forms\Components\Section::make('Financial Details')
118
+                    ->relationship('account')
119
+                    ->schema([
120
+                        Forms\Components\Select::make('currency_code')
121
+                            ->localizeLabel('Currency')
122
+                            ->relationship('currency', 'name')
123
+                            ->default(CurrencyAccessor::getDefaultCurrency())
124
+                            ->preload()
125
+                            ->searchable()
126
+                            ->live()
127
+                            ->afterStateUpdated(static function (Forms\Set $set, $state, $old, Forms\Get $get) {
128
+                                $starting_balance = CurrencyConverter::convertAndSet($state, $old, $get('starting_balance'));
129
+
130
+                                if ($starting_balance !== null) {
131
+                                    $set('starting_balance', $starting_balance);
132
+                                }
133
+                            })
134
+                            ->required()
135
+                            ->createOptionForm([
136
+                                Forms\Components\Select::make('code')
137
+                                    ->localizeLabel()
96 138
                                     ->searchable()
139
+                                    ->options(CurrencyAccessor::getAvailableCurrencies())
97 140
                                     ->live()
98
-                                    ->afterStateUpdated(static function (Forms\Set $set, $state, $old, Forms\Get $get) {
99
-                                        $opening_balance = CurrencyConverter::convertAndSet($state, $old, $get('opening_balance'));
141
+                                    ->afterStateUpdated(static function (callable $set, $state) {
142
+                                        if ($state === null) {
143
+                                            return;
144
+                                        }
145
+
146
+                                        $currency_code = currency($state);
147
+                                        $defaultCurrencyCode = currency()->getCurrency();
148
+                                        $forexEnabled = Forex::isEnabled();
149
+                                        $exchangeRate = $forexEnabled ? Forex::getCachedExchangeRate($defaultCurrencyCode, $state) : null;
100 150
 
101
-                                        if ($opening_balance !== null) {
102
-                                            $set('opening_balance', $opening_balance);
151
+                                        $set('name', $currency_code->getName() ?? '');
152
+
153
+                                        if ($forexEnabled && $exchangeRate !== null) {
154
+                                            $set('rate', $exchangeRate);
103 155
                                         }
104 156
                                     })
105
-                                    ->required()
106
-                                    ->createOptionForm([
107
-                                        Forms\Components\Select::make('currency.code')
108
-                                            ->localizeLabel()
109
-                                            ->searchable()
110
-                                            ->options(CurrencyAccessor::getAvailableCurrencies())
111
-                                            ->live()
112
-                                            ->afterStateUpdated(static function (callable $set, $state) {
113
-                                                if ($state === null) {
114
-                                                    return;
115
-                                                }
116
-
117
-                                                $currency_code = currency($state);
118
-                                                $defaultCurrencyCode = currency()->getCurrency();
119
-                                                $forexEnabled = Forex::isEnabled();
120
-                                                $exchangeRate = $forexEnabled ? Forex::getCachedExchangeRate($defaultCurrencyCode, $state) : null;
121
-
122
-                                                $set('currency.name', $currency_code->getName() ?? '');
123
-
124
-                                                if ($forexEnabled && $exchangeRate !== null) {
125
-                                                    $set('currency.rate', $exchangeRate);
126
-                                                }
127
-                                            })
128
-                                            ->required(),
129
-                                        Forms\Components\TextInput::make('currency.name')
130
-                                            ->localizeLabel()
131
-                                            ->maxLength(100)
132
-                                            ->required(),
133
-                                        Forms\Components\TextInput::make('currency.rate')
134
-                                            ->localizeLabel()
135
-                                            ->numeric()
136
-                                            ->required(),
137
-                                    ])->createOptionAction(static function (Forms\Components\Actions\Action $action) {
138
-                                        return $action
139
-                                            ->label('Add Currency')
140
-                                            ->slideOver()
141
-                                            ->modalWidth('md')
142
-                                            ->action(static function (array $data) {
143
-                                                return DB::transaction(static function () use ($data) {
144
-                                                    $code = $data['currency']['code'];
145
-                                                    $name = $data['currency']['name'];
146
-                                                    $rate = $data['currency']['rate'];
147
-
148
-                                                    return (new CreateCurrency())->create($code, $name, $rate);
149
-                                                });
150
-                                            });
151
-                                    }),
152
-                                Forms\Components\TextInput::make('opening_balance')
153
-                                    ->required()
157
+                                    ->required(),
158
+                                Forms\Components\TextInput::make('name')
154 159
                                     ->localizeLabel()
155
-                                    ->disabledOn('edit')
156
-                                    ->dehydrated()
157
-                                    ->money(static fn (Forms\Get $get) => $get('currency_code')),
158
-                            ])->columns(),
159
-                        Forms\Components\Tabs::make('Account Specifications')
160
-                            ->tabs([
161
-                                Forms\Components\Tabs\Tab::make('Bank Information')
162
-                                    ->icon('heroicon-o-credit-card')
163
-                                    ->schema([
164
-                                        Forms\Components\TextInput::make('bank_name')
165
-                                            ->localizeLabel()
166
-                                            ->maxLength(100),
167
-                                        Forms\Components\TextInput::make('bank_phone')
168
-                                            ->tel()
169
-                                            ->localizeLabel()
170
-                                            ->maxLength(20),
171
-                                        Forms\Components\Textarea::make('bank_address')
172
-                                            ->localizeLabel()
173
-                                            ->columnSpanFull(),
174
-                                    ])->columns(),
175
-                                Forms\Components\Tabs\Tab::make('Additional Information')
176
-                                    ->icon('heroicon-o-information-circle')
177
-                                    ->schema([
178
-                                        Forms\Components\TextInput::make('description')
179
-                                            ->localizeLabel()
180
-                                            ->maxLength(100),
181
-                                        Forms\Components\SpatieTagsInput::make('tags')
182
-                                            ->localizeLabel()
183
-                                            ->placeholder('Enter tags...')
184
-                                            ->type('statuses')
185
-                                            ->suggestions([
186
-                                                'Business',
187
-                                                'Personal',
188
-                                                'College Fund',
189
-                                            ]),
190
-                                        Forms\Components\MarkdownEditor::make('notes')
191
-                                            ->columnSpanFull(),
192
-                                    ])->columns(),
193
-                            ]),
194
-                    ])->columnSpan(['lg' => 2]),
160
+                                    ->maxLength(100)
161
+                                    ->required(),
162
+                                Forms\Components\TextInput::make('rate')
163
+                                    ->localizeLabel()
164
+                                    ->numeric()
165
+                                    ->required(),
166
+                            ])->createOptionAction(static function (Forms\Components\Actions\Action $action) {
167
+                                return $action
168
+                                    ->label('Add Currency')
169
+                                    ->slideOver()
170
+                                    ->modalWidth('md')
171
+                                    ->action(static function (array $data) {
172
+                                        return DB::transaction(static function () use ($data) {
173
+                                            $code = $data['code'];
174
+                                            $name = $data['name'];
175
+                                            $rate = $data['rate'];
195 176
 
196
-                Forms\Components\Group::make()
197
-                    ->schema([
198
-                        Forms\Components\Section::make('Routing Information')
199
-                            ->schema([
200
-                                Forms\Components\TextInput::make('aba_routing_number')
201
-                                    ->localizeLabel('ABA Number')
202
-                                    ->integer()
203
-                                    ->length(9),
204
-                                Forms\Components\TextInput::make('ach_routing_number')
205
-                                    ->localizeLabel('ACH Number')
206
-                                    ->integer()
207
-                                    ->length(9),
208
-                            ]),
209
-                        Forms\Components\Section::make('International Bank Information')
210
-                            ->schema([
211
-                                Forms\Components\TextInput::make('bic_swift_code')
212
-                                    ->localizeLabel('BIC/SWIFT Code')
213
-                                    ->maxLength(11),
214
-                                Forms\Components\TextInput::make('iban')
215
-                                    ->localizeLabel('IBAN')
216
-                                    ->maxLength(34),
217
-                            ]),
218
-                    ])->columnSpan(['lg' => 1]),
219
-            ])->columns(3);
177
+                                            return (new CreateCurrency())->create($code, $name, $rate);
178
+                                        });
179
+                                    });
180
+                            }),
181
+                        Forms\Components\TextInput::make('starting_balance')
182
+                            ->required()
183
+                            ->localizeLabel()
184
+                            ->dehydrated()
185
+                            ->money(static fn (Forms\Get $get) => $get('currency_code')),
186
+                    ])->columns(),
187
+            ]);
220 188
     }
221 189
 
222 190
     public static function table(Table $table): Table
223 191
     {
224 192
         return $table
225 193
             ->columns([
226
-                Tables\Columns\TextColumn::make('name')
194
+                Tables\Columns\TextColumn::make('account.name')
227 195
                     ->localizeLabel('Account')
228 196
                     ->searchable()
229 197
                     ->weight(FontWeight::Medium)
230
-                    ->icon(static fn (Account $record) => $record->isEnabled() ? 'heroicon-o-lock-closed' : null)
231
-                    ->tooltip(static fn (Account $record) => $record->isEnabled() ? 'Default Account' : null)
198
+                    ->icon(static fn (BankAccount $record) => $record->isEnabled() ? 'heroicon-o-lock-closed' : null)
199
+                    ->tooltip(static fn (BankAccount $record) => $record->isEnabled() ? 'Default Account' : null)
232 200
                     ->iconPosition('after')
233
-                    ->description(static fn (Account $record) => $record->number ?: 'N/A')
234
-                    ->sortable(),
235
-                Tables\Columns\TextColumn::make('bank_name')
236
-                    ->localizeLabel('Bank')
237
-                    ->placeholder('N/A')
238
-                    ->description(static fn (Account $record) => $record->bank_phone ?: 'N/A')
239
-                    ->searchable()
240
-                    ->sortable(),
241
-                Tables\Columns\TextColumn::make('status')
242
-                    ->badge()
243
-                    ->localizeLabel()
201
+                    ->description(static fn (BankAccount $record) => $record->number ?: 'N/A')
244 202
                     ->sortable(),
245
-                Tables\Columns\TextColumn::make('balance')
203
+                Tables\Columns\TextColumn::make('account.starting_balance')
246 204
                     ->localizeLabel('Current Balance')
247 205
                     ->sortable()
248
-                    ->currency(static fn (Account $record) => $record->currency_code, true),
206
+                    ->currency(static fn (BankAccount $record) => $record->account->currency_code, true),
249 207
             ])
250 208
             ->filters([
251 209
                 //
@@ -253,10 +211,10 @@ class AccountResource extends Resource
253 211
             ->actions([
254 212
                 Tables\Actions\EditAction::make(),
255 213
                 Tables\Actions\Action::make('update_balance')
256
-                    ->hidden(function (Account $record) {
257
-                        $usesDefaultCurrency = $record->currency->isEnabled();
214
+                    ->hidden(function (BankAccount $record) {
215
+                        $usesDefaultCurrency = $record->account->currency->isEnabled();
258 216
                         $forexDisabled = Forex::isDisabled();
259
-                        $sameExchangeRate = $record->currency->rate === $record->currency->live_rate;
217
+                        $sameExchangeRate = $record->account->currency->rate === $record->account->currency->live_rate;
260 218
 
261 219
                         return $usesDefaultCurrency || $forexDisabled || $sameExchangeRate;
262 220
                     })
@@ -264,10 +222,10 @@ class AccountResource extends Resource
264 222
                     ->icon('heroicon-o-currency-dollar')
265 223
                     ->requiresConfirmation()
266 224
                     ->modalDescription('Are you sure you want to update the balance with the latest exchange rate?')
267
-                    ->before(static function (Tables\Actions\Action $action, Account $record) {
268
-                        if ($record->currency->isDisabled()) {
225
+                    ->before(static function (Tables\Actions\Action $action, BankAccount $record) {
226
+                        if ($record->account->currency->isDisabled()) {
269 227
                             $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
270
-                            $exchangeRate = Forex::getCachedExchangeRate($defaultCurrency, $record->currency_code);
228
+                            $exchangeRate = Forex::getCachedExchangeRate($defaultCurrency, $record->account->currency_code);
271 229
                             if ($exchangeRate === null) {
272 230
                                 Notification::make()
273 231
                                     ->warning()
@@ -280,30 +238,30 @@ class AccountResource extends Resource
280 238
                             }
281 239
                         }
282 240
                     })
283
-                    ->action(static function (Account $record) {
284
-                        if ($record->currency->isDisabled()) {
241
+                    ->action(static function (BankAccount $record) {
242
+                        if ($record->account->currency->isDisabled()) {
285 243
                             $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
286
-                            $exchangeRate = Forex::getCachedExchangeRate($defaultCurrency, $record->currency_code);
287
-                            $oldExchangeRate = $record->currency->rate;
244
+                            $exchangeRate = Forex::getCachedExchangeRate($defaultCurrency, $record->account->currency_code);
245
+                            $oldExchangeRate = $record->account->currency->rate;
288 246
 
289 247
                             if ($exchangeRate !== null && $exchangeRate !== $oldExchangeRate) {
290 248
 
291
-                                $scale = 10 ** $record->currency->precision;
292
-                                $cleanedBalance = (int) filter_var($record->opening_balance, FILTER_SANITIZE_NUMBER_INT);
249
+                                $scale = 10 ** $record->account->currency->precision;
250
+                                $cleanedBalance = (int) filter_var($record->account->starting_balance, FILTER_SANITIZE_NUMBER_INT);
293 251
 
294 252
                                 $newBalance = ($exchangeRate / $oldExchangeRate) * $cleanedBalance;
295 253
                                 $newBalanceInt = (int) round($newBalance, $scale);
296 254
 
297
-                                $record->opening_balance = money($newBalanceInt, $record->currency_code)->getValue();
298
-                                $record->currency->rate = $exchangeRate;
255
+                                $record->account->starting_balance = money($newBalanceInt, $record->account->currency_code)->getValue();
256
+                                $record->account->currency->rate = $exchangeRate;
299 257
 
300
-                                $record->currency->save();
258
+                                $record->account->currency->save();
301 259
                                 $record->save();
302 260
 
303 261
                                 Notification::make()
304 262
                                     ->success()
305 263
                                     ->title('Balance Updated Successfully')
306
-                                    ->body(__('The :name account balance has been updated to reflect the current exchange rate.', ['name' => $record->name]))
264
+                                    ->body(__('The :name account balance has been updated to reflect the current exchange rate.', ['name' => $record->account->name]))
307 265
                                     ->send();
308 266
                             }
309 267
                         }
@@ -314,7 +272,6 @@ class AccountResource extends Resource
314 272
                     Tables\Actions\DeleteBulkAction::make(),
315 273
                 ]),
316 274
             ])
317
-            ->checkIfRecordIsSelectableUsing(static fn (Account $record) => $record->isDisabled())
318 275
             ->emptyStateActions([
319 276
                 Tables\Actions\CreateAction::make(),
320 277
             ]);
@@ -328,4 +285,23 @@ class AccountResource extends Resource
328 285
             'edit' => Pages\EditAccount::route('/{record}/edit'),
329 286
         ];
330 287
     }
288
+
289
+    public static function groupSubtypesBySubtypeType($typeString): array
290
+    {
291
+        $category = match ($typeString) {
292
+            BankAccountType::Depository->value, BankAccountType::Investment->value => AccountCategory::Asset,
293
+            BankAccountType::Credit->value, BankAccountType::Loan->value => AccountCategory::Liability,
294
+            default => null,
295
+        };
296
+
297
+        if ($category === null) {
298
+            return [];
299
+        }
300
+
301
+        $subtypes = AccountSubtype::where('category', $category)->get();
302
+
303
+        return $subtypes->groupBy(fn(AccountSubtype $subtype) => $subtype->type->getLabel())
304
+            ->map(fn(Collection $subtypes, string $type) => $subtypes->mapWithKeys(static fn (AccountSubtype $subtype) => [$subtype->id => $subtype->name]))
305
+            ->toArray();
306
+    }
331 307
 }

+ 6
- 11
app/Filament/Company/Resources/Banking/AccountResource/Pages/CreateAccount.php Bestand weergeven

@@ -3,16 +3,16 @@
3 3
 namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
4 4
 
5 5
 use App\Filament\Company\Resources\Banking\AccountResource;
6
-use App\Models\Banking\Account;
6
+use App\Models\Banking\BankAccount;
7 7
 use App\Traits\HandlesResourceRecordCreation;
8 8
 use Filament\Resources\Pages\CreateRecord;
9 9
 use Filament\Support\Exceptions\Halt;
10 10
 use Illuminate\Database\Eloquent\Model;
11 11
 use Illuminate\Support\Facades\Auth;
12
+use Illuminate\Support\Facades\Log;
12 13
 
13 14
 class CreateAccount extends CreateRecord
14 15
 {
15
-    use HandlesResourceRecordCreation;
16 16
 
17 17
     protected static string $resource = AccountResource::class;
18 18
 
@@ -25,20 +25,15 @@ class CreateAccount extends CreateRecord
25 25
     {
26 26
         $data['enabled'] = (bool) ($data['enabled'] ?? false);
27 27
 
28
+        Log::info('CreateAccount::mutateFormDataBeforeCreate', $data);
29
+
28 30
         return $data;
29 31
     }
30 32
 
31
-    /**
32
-     * @throws Halt
33
-     */
34 33
     protected function handleRecordCreation(array $data): Model
35 34
     {
36
-        $user = Auth::user();
37
-
38
-        if (! $user) {
39
-            throw new Halt('No authenticated user found.');
40
-        }
35
+        Log::info('CreateAccount::handleRecordCreation', $data);
41 36
 
42
-        return $this->handleRecordCreationWithUniqueField($data, new Account(), $user);
37
+        return parent::handleRecordCreation($data);
43 38
     }
44 39
 }

+ 1
- 16
app/Filament/Company/Resources/Banking/AccountResource/Pages/EditAccount.php Bestand weergeven

@@ -3,7 +3,6 @@
3 3
 namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
4 4
 
5 5
 use App\Filament\Company\Resources\Banking\AccountResource;
6
-use App\Models\Banking\Account;
7 6
 use App\Traits\HandlesResourceRecordUpdate;
8 7
 use Filament\Actions;
9 8
 use Filament\Resources\Pages\EditRecord;
@@ -31,22 +30,8 @@ class EditAccount extends EditRecord
31 30
 
32 31
     protected function mutateFormDataBeforeSave(array $data): array
33 32
     {
34
-        $data['enabled'] = (bool) $data['enabled'];
33
+        $data['enabled'] = (bool) ($data['enabled'] ?? false);
35 34
 
36 35
         return $data;
37 36
     }
38
-
39
-    /**
40
-     * @throws Halt
41
-     */
42
-    protected function handleRecordUpdate(Account | Model $record, array $data): Model | Account
43
-    {
44
-        $user = Auth::user();
45
-
46
-        if (! $user) {
47
-            throw new Halt('No authenticated user found.');
48
-        }
49
-
50
-        return $this->handleRecordUpdateWithUniqueField($record, $data, $user);
51
-    }
52 37
 }

+ 1
- 5
app/Filament/Company/Resources/Core/DepartmentResource/RelationManagers/ChildrenRelationManager.php Bestand weergeven

@@ -22,7 +22,6 @@ class ChildrenRelationManager extends RelationManager
22 22
             ->schema([
23 23
                 Forms\Components\TextInput::make('name')
24 24
                     ->localizeLabel()
25
-                    ->autofocus()
26 25
                     ->required()
27 26
                     ->maxLength(100),
28 27
                 Forms\Components\Select::make('manager_id')
@@ -40,10 +39,7 @@ class ChildrenRelationManager extends RelationManager
40 39
                     ->searchable()
41 40
                     ->preload()
42 41
                     ->nullable(),
43
-                Forms\Components\Textarea::make('description')
44
-                    ->localizeLabel()
45
-                    ->autosize()
46
-                    ->nullable(),
42
+                Forms\Components\MarkdownEditor::make('description')->required(),
47 43
             ]);
48 44
     }
49 45
 

+ 80
- 47
app/Filament/Company/Resources/Setting/CategoryResource.php Bestand weergeven

@@ -2,10 +2,12 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Setting;
4 4
 
5
+use App\Enums\Accounting\AccountCategory;
5 6
 use App\Enums\CategoryType;
6 7
 use App\Filament\Company\Resources\Setting\CategoryResource\Pages;
8
+use App\Models\Accounting\Account;
7 9
 use App\Models\Setting\Category;
8
-use App\Traits\NotifiesOnDelete;
10
+use BackedEnum;
9 11
 use Closure;
10 12
 use Exception;
11 13
 use Filament\Facades\Filament;
@@ -19,8 +21,6 @@ use Wallo\FilamentSelectify\Components\ToggleButton;
19 21
 
20 22
 class CategoryResource extends Resource
21 23
 {
22
-    use NotifiesOnDelete;
23
-
24 24
     protected static ?string $model = Category::class;
25 25
 
26 26
     protected static ?string $modelLabel = 'Category';
@@ -50,44 +50,62 @@ class CategoryResource extends Resource
50 50
     public static function form(Form $form): Form
51 51
     {
52 52
         return $form
53
+            ->columns(1)
53 54
             ->schema([
54
-                Forms\Components\Section::make('General')
55
-                    ->schema([
56
-                        Forms\Components\TextInput::make('name')
57
-                            ->localizeLabel()
58
-                            ->autofocus()
59
-                            ->required()
60
-                            ->maxLength(255)
61
-                            ->rule(static function (Forms\Get $get, Forms\Components\Component $component): Closure {
62
-                                return static function (string $attribute, $value, Closure $fail) use ($get, $component) {
63
-                                    $existingCategory = Category::where('company_id', auth()->user()->currentCompany->id)
64
-                                        ->where('name', $value)
65
-                                        ->where('type', $get('type'))
66
-                                        ->first();
67
-
68
-                                    if ($existingCategory && $existingCategory->getKey() !== $component->getRecord()?->getKey()) {
69
-                                        $message = translate('The :Type :record ":name" already exists.', [
70
-                                            'Type' => $existingCategory->type->getLabel(),
71
-                                            'record' => strtolower(static::getModelLabel()),
72
-                                            'name' => $value,
73
-                                        ]);
74
-
75
-                                        $fail($message);
76
-                                    }
77
-                                };
78
-                            }),
79
-                        Forms\Components\Select::make('type')
80
-                            ->localizeLabel()
81
-                            ->options(CategoryType::class)
82
-                            ->required(),
83
-                        Forms\Components\ColorPicker::make('color')
84
-                            ->localizeLabel()
85
-                            ->required(),
86
-                        ToggleButton::make('enabled')
87
-                            ->localizeLabel('Default')
88
-                            ->onLabel(Category::enabledLabel())
89
-                            ->offLabel(Category::disabledLabel()),
90
-                    ])->columns(),
55
+                Forms\Components\TextInput::make('name')
56
+                    ->localizeLabel()
57
+                    ->required()
58
+                    ->maxLength(255)
59
+                    ->rule(static function (Forms\Get $get, Forms\Components\Component $component): Closure {
60
+                        return static function (string $attribute, $value, Closure $fail) use ($get, $component) {
61
+                            $existingCategory = Category::where('company_id', auth()->user()->currentCompany->id)
62
+                                ->where('name', $value)
63
+                                ->where('type', $get('type'))
64
+                                ->first();
65
+
66
+                            if ($existingCategory && $existingCategory->getKey() !== $component->getRecord()?->getKey()) {
67
+                                $message = translate('The :Type :record ":name" already exists.', [
68
+                                    'Type' => $existingCategory->type->getLabel(),
69
+                                    'record' => strtolower(static::getModelLabel()),
70
+                                    'name' => $value,
71
+                                ]);
72
+
73
+                                $fail($message);
74
+                            }
75
+                        };
76
+                    }),
77
+                Forms\Components\Select::make('type')
78
+                    ->localizeLabel()
79
+                    ->options(CategoryType::class)
80
+                    ->live()
81
+                    ->required()
82
+                    ->afterStateUpdated(static function (Forms\Set $set, $state, ?Category $record, string $operation) {
83
+                        $accountOptions = static::getAccountOptions($state);
84
+
85
+                        if ($operation === 'create') {
86
+                            $set('account_id', null);
87
+                        } elseif ($operation === 'edit' && $record !== null) {
88
+                            if (! array_key_exists($record->account_id, $accountOptions)) {
89
+                                $set('account_id', null);
90
+                            } else {
91
+                                $set('account_id', $record->account_id);
92
+                            }
93
+                        }
94
+                    }),
95
+                Forms\Components\Select::make('account_id')
96
+                    ->label('Account')
97
+                    ->searchable()
98
+                    ->preload()
99
+                    ->options(static function (Forms\Get $get) {
100
+                        return static::getAccountOptions($get('type'));
101
+                    }),
102
+                Forms\Components\ColorPicker::make('color')
103
+                    ->localizeLabel()
104
+                    ->required(),
105
+                ToggleButton::make('enabled')
106
+                    ->localizeLabel('Default')
107
+                    ->onLabel(Category::enabledLabel())
108
+                    ->offLabel(Category::disabledLabel()),
91 109
             ]);
92 110
     }
93 111
 
@@ -125,6 +143,7 @@ class CategoryResource extends Resource
125 143
                 Tables\Filters\SelectFilter::make('type')
126 144
                     ->label('Type')
127 145
                     ->multiple()
146
+                    ->searchable()
128 147
                     ->options(CategoryType::class),
129 148
             ])
130 149
             ->actions([
@@ -138,18 +157,32 @@ class CategoryResource extends Resource
138 157
             ])
139 158
             ->checkIfRecordIsSelectableUsing(static function (Category $record) {
140 159
                 return $record->isDisabled();
141
-            })
142
-            ->emptyStateActions([
143
-                Tables\Actions\CreateAction::make(),
144
-            ]);
160
+            });
145 161
     }
146 162
 
147 163
     public static function getPages(): array
148 164
     {
149 165
         return [
150
-            'index' => Pages\ListCategories::route('/'),
151
-            'create' => Pages\CreateCategory::route('/create'),
152
-            'edit' => Pages\EditCategory::route('/{record}/edit'),
166
+            'index' => Pages\ManageCategory::route('/'),
153 167
         ];
154 168
     }
169
+
170
+    public static function getAccountOptions($typeValue): array
171
+    {
172
+        $typeString = $typeValue instanceof BackedEnum ? $typeValue->value : $typeValue;
173
+
174
+        $accountCategory = match ($typeString) {
175
+            CategoryType::Income->value => AccountCategory::Revenue,
176
+            CategoryType::Expense->value => AccountCategory::Expense,
177
+            default => null,
178
+        };
179
+
180
+        if ($accountCategory) {
181
+            $accounts = Account::where('category', $accountCategory)->get();
182
+        } else {
183
+            $accounts = Account::all();
184
+        }
185
+
186
+        return $accounts->pluck('name', 'id')->toArray();
187
+    }
155 188
 }

+ 0
- 46
app/Filament/Company/Resources/Setting/CategoryResource/Pages/CreateCategory.php Bestand weergeven

@@ -1,46 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Resources\Setting\CategoryResource\Pages;
4
-
5
-use App\Enums\CategoryType;
6
-use App\Filament\Company\Resources\Setting\CategoryResource;
7
-use App\Models\Setting\Category;
8
-use App\Traits\HandlesResourceRecordCreation;
9
-use Filament\Resources\Pages\CreateRecord;
10
-use Filament\Support\Exceptions\Halt;
11
-use Illuminate\Database\Eloquent\Model;
12
-
13
-class CreateCategory extends CreateRecord
14
-{
15
-    use HandlesResourceRecordCreation;
16
-
17
-    protected static string $resource = CategoryResource::class;
18
-
19
-    protected function getRedirectUrl(): string
20
-    {
21
-        return $this->previousUrl;
22
-    }
23
-
24
-    protected function mutateFormDataBeforeCreate(array $data): array
25
-    {
26
-        $data['enabled'] = (bool) $data['enabled'];
27
-
28
-        return $data;
29
-    }
30
-
31
-    /**
32
-     * @throws Halt
33
-     */
34
-    protected function handleRecordCreation(array $data): Model
35
-    {
36
-        $user = auth()->user();
37
-
38
-        if (! $user) {
39
-            throw new Halt('No authenticated user found');
40
-        }
41
-
42
-        $evaluatedTypes = [CategoryType::Income, CategoryType::Expense];
43
-
44
-        return $this->handleRecordCreationWithUniqueField($data, new Category(), $user, 'type', $evaluatedTypes);
45
-    }
46
-}

+ 0
- 53
app/Filament/Company/Resources/Setting/CategoryResource/Pages/EditCategory.php Bestand weergeven

@@ -1,53 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Resources\Setting\CategoryResource\Pages;
4
-
5
-use App\Enums\CategoryType;
6
-use App\Filament\Company\Resources\Setting\CategoryResource;
7
-use App\Traits\HandlesResourceRecordUpdate;
8
-use Filament\Actions;
9
-use Filament\Resources\Pages\EditRecord;
10
-use Filament\Support\Exceptions\Halt;
11
-use Illuminate\Database\Eloquent\Model;
12
-
13
-class EditCategory extends EditRecord
14
-{
15
-    use HandlesResourceRecordUpdate;
16
-
17
-    protected static string $resource = CategoryResource::class;
18
-
19
-    protected function getHeaderActions(): array
20
-    {
21
-        return [
22
-            Actions\DeleteAction::make(),
23
-        ];
24
-    }
25
-
26
-    protected function getRedirectUrl(): string
27
-    {
28
-        return $this->previousUrl;
29
-    }
30
-
31
-    protected function mutateFormDataBeforeSave(array $data): array
32
-    {
33
-        $data['enabled'] = (bool) $data['enabled'];
34
-
35
-        return $data;
36
-    }
37
-
38
-    /**
39
-     * @throws Halt
40
-     */
41
-    protected function handleRecordUpdate(Model $record, array $data): Model
42
-    {
43
-        $user = auth()->user();
44
-
45
-        if (! $user) {
46
-            throw new Halt('No authenticated user found');
47
-        }
48
-
49
-        $evaluatedTypes = [CategoryType::Income, CategoryType::Expense];
50
-
51
-        return $this->handleRecordUpdateWithUniqueField($record, $data, $user, 'type', $evaluatedTypes);
52
-    }
53
-}

+ 0
- 19
app/Filament/Company/Resources/Setting/CategoryResource/Pages/ListCategories.php Bestand weergeven

@@ -1,19 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Resources\Setting\CategoryResource\Pages;
4
-
5
-use App\Filament\Company\Resources\Setting\CategoryResource;
6
-use Filament\Actions;
7
-use Filament\Resources\Pages\ListRecords;
8
-
9
-class ListCategories extends ListRecords
10
-{
11
-    protected static string $resource = CategoryResource::class;
12
-
13
-    protected function getHeaderActions(): array
14
-    {
15
-        return [
16
-            Actions\CreateAction::make(),
17
-        ];
18
-    }
19
-}

+ 52
- 0
app/Filament/Company/Resources/Setting/CategoryResource/Pages/ManageCategory.php Bestand weergeven

@@ -0,0 +1,52 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Setting\CategoryResource\Pages;
4
+
5
+use App\Enums\CategoryType;
6
+use App\Filament\Company\Resources\Setting\CategoryResource;
7
+use App\Models\Setting\Category;
8
+use App\Traits\HandlesResourceRecordCreation;
9
+use App\Traits\HandlesResourceRecordUpdate;
10
+use Filament\Actions;
11
+use Filament\Resources\Pages\ManageRecords;
12
+use Filament\Support\Enums\MaxWidth;
13
+use Filament\Tables;
14
+use Illuminate\Database\Eloquent\Model;
15
+
16
+class ManageCategory extends ManageRecords
17
+{
18
+    use HandlesResourceRecordCreation;
19
+    use HandlesResourceRecordUpdate;
20
+
21
+    protected static string $resource = CategoryResource::class;
22
+
23
+    protected function getHeaderActions(): array
24
+    {
25
+        return [
26
+            Actions\CreateAction::make()
27
+                ->using(function (array $data): Model {
28
+                    $user = auth()->user();
29
+
30
+                    $evaluatedTypes = [CategoryType::Income, CategoryType::Expense];
31
+
32
+                    return $this->handleRecordCreationWithUniqueField($data, new Category(), $user, 'type', $evaluatedTypes);
33
+                })
34
+                ->modalWidth(MaxWidth::TwoExtraLarge),
35
+        ];
36
+    }
37
+
38
+    protected function configureEditAction(Tables\Actions\EditAction $action): void
39
+    {
40
+        parent::configureEditAction($action);
41
+
42
+        $action
43
+            ->modalWidth(MaxWidth::TwoExtraLarge)
44
+            ->using(function (Model $record, array $data): Model {
45
+                $user = auth()->user();
46
+
47
+                $evaluatedTypes = [CategoryType::Income, CategoryType::Expense];
48
+
49
+                return $this->handleRecordUpdateWithUniqueField($record, $data, $user, 'type', $evaluatedTypes);
50
+            });
51
+    }
52
+}

+ 38
- 0
app/Http/Controllers/PlaidController.php Bestand weergeven

@@ -0,0 +1,38 @@
1
+<?php
2
+
3
+namespace App\Http\Controllers;
4
+
5
+use App\Services\PlaidService;
6
+use Illuminate\Http\Request;
7
+use Illuminate\Routing\Controller;
8
+
9
+class PlaidController extends Controller
10
+{
11
+    /**
12
+     * Handle the incoming request.
13
+     */
14
+    public function __invoke(Request $request, PlaidService $plaidService)
15
+    {
16
+        $publicTokenResponse = $plaidService->createSandboxPublicToken();
17
+        $publicToken = $publicTokenResponse->public_token;
18
+
19
+        $accessTokenResponse = $plaidService->exchangePublicToken($publicToken);
20
+        $accessToken = $accessTokenResponse->access_token;
21
+
22
+        $authResponse = $plaidService->getAuth($accessToken);
23
+        $account = $authResponse;
24
+
25
+        $institutionId = $account->item->institution_id;
26
+
27
+        $institutionResponse = $plaidService->getInstitution($institutionId);
28
+
29
+        return response()->json([
30
+            'public_token' => $publicToken,
31
+            'public_token_response' => $publicTokenResponse,
32
+            'access_token_response' => $accessTokenResponse,
33
+            'access_token' => $accessToken,
34
+            'account' => $account,
35
+            'institution' => $institutionResponse,
36
+        ]);
37
+    }
38
+}

+ 92
- 0
app/Listeners/ConfigureChartOfAccounts.php Bestand weergeven

@@ -0,0 +1,92 @@
1
+<?php
2
+
3
+namespace App\Listeners;
4
+
5
+use App\Enums\Accounting\AccountType;
6
+use App\Events\CompanyGenerated;
7
+use App\Models\Accounting\Account;
8
+use App\Models\Accounting\AccountSubtype;
9
+use App\Models\Company;
10
+use App\Utilities\Currency\CurrencyAccessor;
11
+
12
+class ConfigureChartOfAccounts
13
+{
14
+    /**
15
+     * Create the event listener.
16
+     */
17
+    public function __construct()
18
+    {
19
+        //
20
+    }
21
+
22
+    /**
23
+     * Handle the event.
24
+     */
25
+    public function handle(CompanyGenerated $event): void
26
+    {
27
+        $company = $event->company;
28
+
29
+        $this->createChartOfAccounts($company);
30
+    }
31
+
32
+    public function createChartOfAccounts(Company $company): void
33
+    {
34
+        $chartOfAccounts = config('chart-of-accounts.default');
35
+
36
+        foreach ($chartOfAccounts as $type => $subtypes) {
37
+            foreach ($subtypes as $subtypeName => $subtypeConfig) {
38
+                $subtype = AccountSubtype::create([
39
+                    'company_id' => $company->id,
40
+                    'multi_currency' => $subtypeConfig['multi_currency'] ?? false,
41
+                    'category' => AccountType::from($type)->getCategory()->value,
42
+                    'type' => $type,
43
+                    'name' => $subtypeName,
44
+                    'description' => $subtypeConfig['description'] ?? 'No description available.',
45
+                ]);
46
+
47
+                $this->createDefaultAccounts($company, $subtype, $subtypeConfig);
48
+            }
49
+        }
50
+
51
+        $this->linkCategoriesToAccounts($company);
52
+    }
53
+
54
+    private function createDefaultAccounts(Company $company, AccountSubtype $subtype, array $subtypeConfig): void
55
+    {
56
+        if (isset($subtypeConfig['accounts']) && is_array($subtypeConfig['accounts'])) {
57
+            $baseCode = $subtypeConfig['base_code'];
58
+
59
+            foreach ($subtypeConfig['accounts'] as $accountName => $accountDetails) {
60
+                Account::create([
61
+                    'company_id' => $company->id,
62
+                    'category' => $subtype->type->getCategory()->value,
63
+                    'type' => $subtype->type->value,
64
+                    'subtype_id' => $subtype->id,
65
+                    'code' => $baseCode++,
66
+                    'name' => $accountName,
67
+                    'description' => $accountDetails['description'] ?? 'No description available.',
68
+                    'ending_balance' => 0,
69
+                    'active' => true,
70
+                    'default' => true,
71
+                    'currency_code' => CurrencyAccessor::getDefaultCurrency(),
72
+                    'created_by' => $company->owner->id,
73
+                    'updated_by' => $company->owner->id,
74
+                ]);
75
+            }
76
+        }
77
+    }
78
+
79
+    private function linkCategoriesToAccounts(Company $company): void
80
+    {
81
+        $categories = config('chart-of-accounts.category_account_map');
82
+
83
+        foreach ($categories as $categoryName => $accountName) {
84
+            $category = $company->categories()->where('name', $categoryName)->first();
85
+            $account = $company->accounts()->where('name', $accountName)->first();
86
+
87
+            if ($category && $account) {
88
+                $category->update(['account_id' => $account->id]);
89
+            }
90
+        }
91
+    }
92
+}

+ 5
- 3
app/Listeners/ConfigureCompanyNavigation.php Bestand weergeven

@@ -3,6 +3,7 @@
3 3
 namespace App\Listeners;
4 4
 
5 5
 use App\Events\CompanyConfigured;
6
+use App\Filament\Company\Pages\Service\ConnectedAccount;
6 7
 use App\Filament\Company\Pages\Service\LiveCurrency;
7 8
 use App\Filament\Company\Pages\Setting\Appearance;
8 9
 use App\Filament\Company\Pages\Setting\CompanyDefault;
@@ -66,7 +67,7 @@ class ConfigureCompanyNavigation
66 67
      */
67 68
     protected function buildSettingsGroup(): NavigationGroup
68 69
     {
69
-        return NavigationGroup::make(translate('Settings'))
70
+        return NavigationGroup::make('Settings')
70 71
             ->items([
71 72
                 ...CategoryResource::getNavigationItems(),
72 73
                 ...CurrencyResource::getNavigationItems(),
@@ -85,10 +86,11 @@ class ConfigureCompanyNavigation
85 86
      */
86 87
     protected function buildResourcesGroup(): NavigationGroup
87 88
     {
88
-        return NavigationGroup::make(translate('Resources'))
89
+        return NavigationGroup::make('Resources')
89 90
             ->items([
90
-                ...LiveCurrency::getNavigationItems(),
91 91
                 ...AccountResource::getNavigationItems(),
92
+                ...ConnectedAccount::getNavigationItems(),
93
+                ...LiveCurrency::getNavigationItems(),
92 94
                 ...DepartmentResource::getNavigationItems(),
93 95
             ]);
94 96
     }

+ 81
- 0
app/Listeners/CreateConnectedAccount.php Bestand weergeven

@@ -0,0 +1,81 @@
1
+<?php
2
+
3
+namespace App\Listeners;
4
+
5
+use App\Events\PlaidSuccess;
6
+use App\Models\Banking\Institution;
7
+use App\Models\Company;
8
+use App\Services\PlaidService;
9
+use Illuminate\Contracts\Queue\ShouldQueue;
10
+use Illuminate\Queue\InteractsWithQueue;
11
+use Illuminate\Support\Facades\DB;
12
+
13
+class CreateConnectedAccount
14
+{
15
+    protected PlaidService $plaid;
16
+
17
+    /**
18
+     * Create the event listener.
19
+     */
20
+    public function __construct(PlaidService $plaid)
21
+    {
22
+        $this->plaid = $plaid;
23
+    }
24
+
25
+    /**
26
+     * Handle the event.
27
+     */
28
+    public function handle(PlaidSuccess $event): void
29
+    {
30
+        DB::transaction(function () use ($event) {
31
+            $this->processPlaidSuccess($event);
32
+        });
33
+    }
34
+
35
+    public function processPlaidSuccess(PlaidSuccess $event): void
36
+    {
37
+        $accessToken = $event->accessToken;
38
+
39
+        $company = $event->company;
40
+
41
+        $authResponse = $this->plaid->getAccounts($accessToken);
42
+
43
+        $institutionResponse = $this->plaid->getInstitution($authResponse->item->institution_id, $company->profile->country);
44
+
45
+        $this->processInstitution($authResponse, $institutionResponse, $company, $accessToken);
46
+    }
47
+
48
+    public function processInstitution($authResponse, $institutionResponse, Company $company, $accessToken): void
49
+    {
50
+        $institution = Institution::updateOrCreate([
51
+            'external_institution_id' => $authResponse->item->institution_id ?? null,
52
+        ], [
53
+            'name' => $institutionResponse->institution->name ?? null,
54
+            'logo' => $institutionResponse->institution->logo ?? null,
55
+            'website' => $institutionResponse->institution->url ?? null,
56
+        ]);
57
+
58
+        foreach ($authResponse->accounts as $plaidAccount) {
59
+            $this->processConnectedBankAccount($plaidAccount, $company, $institution, $authResponse, $accessToken);
60
+        }
61
+    }
62
+
63
+    public function processConnectedBankAccount($plaidAccount, Company $company, Institution $institution, $authResponse, $accessToken): void
64
+    {
65
+        $identifierHash = md5($institution->external_institution_id . $plaidAccount->name . $plaidAccount->mask);
66
+
67
+        $company->connectedBankAccounts()->updateOrCreate([
68
+            'identifier' => $identifierHash,
69
+        ], [
70
+            'institution_id' => $institution->id,
71
+            'external_account_id' => $plaidAccount->account_id,
72
+            'access_token' => $accessToken,
73
+            'item_id' => $authResponse->item->item_id,
74
+            'name' => $plaidAccount->name,
75
+            'mask' => $plaidAccount->mask,
76
+            'type' => $plaidAccount->type,
77
+            'subtype' => $plaidAccount->subtype,
78
+            'import_transactions' => false,
79
+        ]);
80
+    }
81
+}

+ 143
- 0
app/Listeners/HandleTransactionImport.php Bestand weergeven

@@ -0,0 +1,143 @@
1
+<?php
2
+
3
+namespace App\Listeners;
4
+
5
+use App\Events\StartTransactionImport;
6
+use App\Models\Accounting\AccountSubtype;
7
+use App\Models\Banking\BankAccount;
8
+use App\Models\Banking\ConnectedBankAccount;
9
+use App\Models\Company;
10
+use App\Models\Setting\Currency;
11
+use App\Services\PlaidService;
12
+use App\Utilities\Currency\CurrencyAccessor;
13
+use Illuminate\Contracts\Queue\ShouldQueue;
14
+use Illuminate\Queue\InteractsWithQueue;
15
+use Illuminate\Support\Facades\DB;
16
+
17
+class HandleTransactionImport
18
+{
19
+    protected PlaidService $plaid;
20
+
21
+    /**
22
+     * Create the event listener.
23
+     */
24
+    public function __construct(PlaidService $plaid)
25
+    {
26
+        $this->plaid = $plaid;
27
+    }
28
+
29
+    /**
30
+     * Handle the event.
31
+     */
32
+    public function handle(StartTransactionImport $event): void
33
+    {
34
+        DB::transaction(function () use ($event) {
35
+            $this->processTransactionImport($event);
36
+        });
37
+    }
38
+
39
+    public function processTransactionImport(StartTransactionImport $event): void
40
+    {
41
+        $company = $event->company;
42
+        $connectedBankAccountId = $event->connectedBankAccountId;
43
+        $selectedBankAccountId = $event->selectedBankAccountId;
44
+        $startDate = $event->startDate;
45
+
46
+        $connectedBankAccount = ConnectedBankAccount::find($connectedBankAccountId);
47
+
48
+        if ($connectedBankAccount === null) {
49
+            return;
50
+        }
51
+
52
+        $accessToken = $connectedBankAccount->access_token;
53
+
54
+        if ($selectedBankAccountId === 'new') {
55
+            $bankAccount = $this->processNewBankAccount($company, $connectedBankAccount, $accessToken);
56
+        } else {
57
+            $bankAccount = BankAccount::find($selectedBankAccountId);
58
+
59
+            if ($bankAccount === null) {
60
+                return;
61
+            }
62
+        }
63
+
64
+        $connectedBankAccount->update([
65
+            'bank_account_id' => $bankAccount->id,
66
+            'import_transactions' => true,
67
+        ]);
68
+    }
69
+
70
+    public function processNewBankAccount(Company $company, ConnectedBankAccount $connectedBankAccount, $accessToken): BankAccount
71
+    {
72
+        $bankAccount = $connectedBankAccount->bankAccount()->create([
73
+            'company_id' => $company->id,
74
+            'institution_id' => $connectedBankAccount->institution_id,
75
+            'type' => $connectedBankAccount->type,
76
+            'number' => $connectedBankAccount->mask,
77
+            'enabled' => BankAccount::where('company_id', $company->id)->where('enabled', true)->doesntExist(),
78
+        ]);
79
+
80
+        $this->mapAccountDetails($bankAccount, $company, $accessToken, $connectedBankAccount);
81
+
82
+        return $bankAccount;
83
+    }
84
+
85
+    public function mapAccountDetails(BankAccount $bankAccount, Company $company, $accessToken, ConnectedBankAccount $connectedBankAccount): void
86
+    {
87
+        $this->ensureCurrencyExists($company->id, 'USD');
88
+
89
+        $accountSubtype = $this->getAccountSubtype($bankAccount->type->value);
90
+
91
+        $accountSubtypeId = $this->resolveAccountSubtypeId($company, $accountSubtype);
92
+
93
+        $bankAccount->account()->create([
94
+            'company_id' => $company->id,
95
+            'name' => $connectedBankAccount->name,
96
+            'currency_code' => 'USD',
97
+            'description' => $connectedBankAccount->name,
98
+            'subtype_id' => $accountSubtypeId,
99
+            'active' => true,
100
+        ]);
101
+    }
102
+
103
+    public function ensureCurrencyExists(int $companyId, string $currencyCode): void
104
+    {
105
+        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
106
+
107
+        $hasDefaultCurrency = $defaultCurrency !== null;
108
+
109
+        $currency_code = currency($currencyCode);
110
+
111
+        Currency::firstOrCreate([
112
+            'company_id' => $companyId,
113
+            'code' => $currencyCode,
114
+        ], [
115
+            'name' => $currency_code->getName(),
116
+            'rate' => $currency_code->getRate(),
117
+            'precision' => $currency_code->getPrecision(),
118
+            'symbol' => $currency_code->getSymbol(),
119
+            'symbol_first' => $currency_code->isSymbolFirst(),
120
+            'decimal_mark' => $currency_code->getDecimalMark(),
121
+            'thousands_separator' => $currency_code->getThousandsSeparator(),
122
+            'enabled' => ! $hasDefaultCurrency,
123
+        ]);
124
+    }
125
+
126
+    public function getAccountSubtype(string $plaidType): string
127
+    {
128
+        return match ($plaidType) {
129
+            'depository' => 'Cash and Cash Equivalents',
130
+            'credit' => 'Short-Term Borrowings',
131
+            'loan' => 'Long-Term Borrowings',
132
+            'investment' => 'Long-Term Investments',
133
+            'other' => 'Other Current Assets',
134
+        };
135
+    }
136
+
137
+    public function resolveAccountSubtypeId(Company $company, string $accountSubtype): int
138
+    {
139
+        return AccountSubtype::where('company_id', $company->id)
140
+            ->where('name', $accountSubtype)
141
+            ->value('id');
142
+    }
143
+}

+ 177
- 0
app/Listeners/PopulateAccountFromPlaid.php Bestand weergeven

@@ -0,0 +1,177 @@
1
+<?php
2
+
3
+namespace App\Listeners;
4
+
5
+use App\Events\PlaidSuccess;
6
+use App\Models\Accounting\AccountSubtype;
7
+use App\Models\Banking\BankAccount;
8
+use App\Models\Banking\Institution;
9
+use App\Models\Company;
10
+use App\Models\Setting\Currency;
11
+use App\Services\PlaidService;
12
+use App\Utilities\Currency\CurrencyAccessor;
13
+use Illuminate\Support\Facades\DB;
14
+
15
+class PopulateAccountFromPlaid
16
+{
17
+    protected PlaidService $plaid;
18
+
19
+    /**
20
+     * Create the event listener.
21
+     */
22
+    public function __construct(PlaidService $plaid)
23
+    {
24
+        $this->plaid = $plaid;
25
+    }
26
+
27
+    /**
28
+     * Handle the event.
29
+     */
30
+    public function handle(PlaidSuccess $event): void
31
+    {
32
+        DB::transaction(function () use ($event) {
33
+            $this->processPlaidSuccess($event);
34
+        });
35
+    }
36
+
37
+    public function processPlaidSuccess(PlaidSuccess $event): void
38
+    {
39
+        $accessToken = $event->accessToken;
40
+
41
+        $company = $event->company;
42
+
43
+        $authResponse = $this->plaid->getAccounts($accessToken);
44
+
45
+        $institutionResponse = $this->plaid->getInstitution($authResponse->item->institution_id, $company->profile->country);
46
+
47
+        $this->processInstitution($authResponse, $institutionResponse, $company);
48
+    }
49
+
50
+    public function processInstitution($authResponse, $institutionResponse, Company $company): void
51
+    {
52
+        $institution = Institution::updateOrCreate([
53
+            'external_institution_id' => $authResponse->item->institution_id ?? null,
54
+        ], [
55
+            'name' => $institutionResponse->institution->name ?? null,
56
+            'logo' => $institutionResponse->institution->logo ?? null,
57
+            'website' => $institutionResponse->institution->url ?? null,
58
+        ]);
59
+
60
+        foreach ($authResponse->accounts as $plaidAccount) {
61
+            $this->processBankAccount($plaidAccount, $company, $institution, $authResponse);
62
+        }
63
+    }
64
+
65
+    public function processBankAccount($plaidAccount, Company $company, Institution $institution, $authResponse): void
66
+    {
67
+        $identifierHash = md5($institution->external_institution_id . $plaidAccount->name . $plaidAccount->mask);
68
+
69
+        $bankAccount = BankAccount::updateOrCreate([
70
+            'company_id' => $company->id,
71
+            'identifier' => $identifierHash,
72
+        ], [
73
+            'is_connected_account' => true,
74
+            'external_account_id' => $plaidAccount->account_id,
75
+            'item_id' => $authResponse->item->item_id,
76
+            'enabled' => BankAccount::where('company_id', $company->id)->where('enabled', true)->doesntExist(),
77
+            'type' => $plaidAccount->type,
78
+            'number' => $plaidAccount->mask,
79
+            'institution_id' => $institution->id,
80
+        ]);
81
+
82
+        $this->mapAccountDetails($bankAccount, $plaidAccount, $company);
83
+    }
84
+
85
+    public function mapAccountDetails(BankAccount $bankAccount, $plaidAccount, Company $company): void
86
+    {
87
+        $this->ensureCurrencyExists($company->id, $plaidAccount->balances->iso_currency_code);
88
+
89
+        $accountSubtype = $this->getAccountSubtype($plaidAccount->type);
90
+
91
+        $accountSubtypeId = $this->resolveAccountSubtypeId($company, $accountSubtype);
92
+
93
+        $bankAccount->account()->updateOrCreate([], [
94
+            'name' => $plaidAccount->name,
95
+            'currency_code' => $plaidAccount->balances->iso_currency_code,
96
+            'description' => $plaidAccount->official_name ?? $plaidAccount->name,
97
+            'subtype_id' => $accountSubtypeId,
98
+            'active' => true,
99
+        ]);
100
+    }
101
+
102
+    public function getAccountSubtype(string $plaidType): string
103
+    {
104
+        return match ($plaidType) {
105
+            'depository' => 'Cash and Cash Equivalents',
106
+            'credit' => 'Short-Term Borrowings',
107
+            'loan' => 'Long-Term Borrowings',
108
+            'investment' => 'Long-Term Investments',
109
+            'other' => 'Other Current Assets',
110
+        };
111
+    }
112
+
113
+    public function resolveAccountSubtypeId(Company $company, string $accountSubtype): int
114
+    {
115
+        return AccountSubtype::where('company_id', $company->id)
116
+            ->where('name', $accountSubtype)
117
+            ->value('id');
118
+    }
119
+
120
+    public function ensureCurrencyExists(int $companyId, string $currencyCode): void
121
+    {
122
+        $defaultCurrency = CurrencyAccessor::getDefaultCurrency();
123
+
124
+        $hasDefaultCurrency = $defaultCurrency !== null;
125
+
126
+        $currency_code = currency($currencyCode);
127
+
128
+        Currency::firstOrCreate([
129
+            'company_id' => $companyId,
130
+            'code' => $currencyCode,
131
+        ], [
132
+            'name' => $currency_code->getName(),
133
+            'rate' => $currency_code->getRate(),
134
+            'precision' => $currency_code->getPrecision(),
135
+            'symbol' => $currency_code->getSymbol(),
136
+            'symbol_first' => $currency_code->isSymbolFirst(),
137
+            'decimal_mark' => $currency_code->getDecimalMark(),
138
+            'thousands_separator' => $currency_code->getThousandsSeparator(),
139
+            'enabled' => ! $hasDefaultCurrency,
140
+        ]);
141
+    }
142
+
143
+    public function getRoutingNumber($accountId, $numbers): array
144
+    {
145
+        foreach ($numbers as $type => $numberList) {
146
+            foreach ($numberList as $number) {
147
+                if ($number->account_id === $accountId) {
148
+                    return match ($type) {
149
+                        'ach' => ['routing_number' => $number->routing],
150
+                        'bacs' => ['routing_number' => $number->sort_code],
151
+                        'eft' => ['routing_number' => $number->branch],
152
+                        'international' => [
153
+                            'bic' => $number->bic,
154
+                            'iban' => $number->iban,
155
+                        ],
156
+                        default => [],
157
+                    };
158
+                }
159
+            }
160
+        }
161
+
162
+        return [];
163
+    }
164
+
165
+    public function getFullAccountNumber($accountId, $numbers)
166
+    {
167
+        foreach ($numbers as $numberList) {
168
+            foreach ($numberList as $number) {
169
+                if ($number->account_id === $accountId && property_exists($number, 'account')) {
170
+                    return $number->account;
171
+                }
172
+            }
173
+        }
174
+
175
+        return null;
176
+    }
177
+}

+ 1
- 1
app/Listeners/SyncAssociatedModels.php Bestand weergeven

@@ -39,7 +39,7 @@ class SyncAssociatedModels
39 39
         $diff = array_diff_assoc($data, $record_array);
40 40
 
41 41
         $keyToMethodMap = [
42
-            'account_id' => 'account',
42
+            'bank_account_id' => 'bankAccount',
43 43
             'currency_code' => 'currency',
44 44
             'sales_tax_id' => 'salesTax',
45 45
             'purchase_tax_id' => 'purchaseTax',

+ 154
- 0
app/Listeners/SyncTransactionsFromPlaid.php Bestand weergeven

@@ -0,0 +1,154 @@
1
+<?php
2
+
3
+namespace App\Listeners;
4
+
5
+use App\Enums\Accounting\AccountType;
6
+use App\Events\PlaidSuccess;
7
+use App\Models\Accounting\Chart;
8
+use App\Models\Company;
9
+use App\Models\Setting\Category;
10
+use App\Services\PlaidService;
11
+use Illuminate\Support\Carbon;
12
+use Illuminate\Support\Str;
13
+
14
+class SyncTransactionsFromPlaid
15
+{
16
+    protected PlaidService $plaid;
17
+
18
+    /**
19
+     * Create the event listener.
20
+     */
21
+    public function __construct(PlaidService $plaid)
22
+    {
23
+        $this->plaid = $plaid;
24
+    }
25
+
26
+    /**
27
+     * Handle the event.
28
+     */
29
+    public function handle(PlaidSuccess $event): void
30
+    {
31
+        $accessToken = $event->accessToken;
32
+        $company = $event->company;
33
+
34
+        $syncResponse = $this->plaid->syncTransactions($accessToken);
35
+
36
+        foreach ($syncResponse->added as $transaction) {
37
+            $this->storeTransaction($company, $transaction);
38
+        }
39
+    }
40
+
41
+    public function storeTransaction(Company $company, $transaction): void
42
+    {
43
+        $account = $company->accounts()->where('external_account_id', $transaction->account_id)->first();
44
+
45
+        if ($account === null) {
46
+            return;
47
+        }
48
+
49
+        $transactionType = $transaction->amount < 0 ? 'income' : 'expense';
50
+        $method = $transactionType === 'income' ? 'deposit' : 'withdrawal';
51
+        $paymentChannel = $transaction->payment_channel ?? 'other';
52
+        $category = $this->getCategoryFromTransaction($company, $transaction, $transactionType);
53
+        $chart = $category->account ?? $this->getChartFromTransaction($company, $transaction, $transactionType);
54
+
55
+        // Use datetime and if null, then use date and convert to datetime
56
+        $postedAt = $transaction->datetime ?? Carbon::parse($transaction->date)->toDateTimeString();
57
+
58
+        $description = $transaction->original_description ?? $transaction->name;
59
+        $cleanDescription = preg_replace('/\\*\\/\\/$/', '', $description);
60
+        $cleanDescription = trim(preg_replace('/\\s+/', ' ', $cleanDescription));
61
+
62
+        $account->transactions()->create([
63
+            'company_id' => $company->id,
64
+            'account_id' => $account->id,
65
+            'category_id' => $category->id,
66
+            'chart_id' => $chart->id,
67
+            'amount' => abs($transaction->amount),
68
+            'type' => $transactionType,
69
+            'method' => $method,
70
+            'payment_channel' => $paymentChannel,
71
+            'posted_at' => $postedAt,
72
+            'description' => $cleanDescription,
73
+            'pending' => $transaction->pending,
74
+            'reviewed' => false,
75
+        ]);
76
+    }
77
+
78
+    public function getCategoryFromTransaction(Company $company, $transaction, string $transactionType): Category
79
+    {
80
+        $acceptableConfidenceLevels = ['VERY_HIGH', 'HIGH'];
81
+
82
+        $userCategories = $company->categories()->get();
83
+        $plaidDetail = $transaction->personal_finance_category->detailed ?? null;
84
+        $plaidPrimary = $transaction->personal_finance_category->primary ?? null;
85
+
86
+        $category = null;
87
+
88
+        if ($plaidDetail !== null && in_array($transaction->personal_finance_category->confidence_level, $acceptableConfidenceLevels, true)) {
89
+            $category = $this->matchCategory($userCategories, $plaidDetail, $transactionType);
90
+        }
91
+
92
+        if ($plaidPrimary !== null && ($category === null || $this->isUncategorized($category))) {
93
+            $category = $this->matchCategory($userCategories, $plaidPrimary, $transactionType);
94
+        }
95
+
96
+        return $category ?? $this->getUncategorizedCategory($company, $transaction, $transactionType);
97
+    }
98
+
99
+    public function isUncategorized(Category $category): bool
100
+    {
101
+        return Str::contains(strtolower($category->name), 'other');
102
+    }
103
+
104
+    public function matchCategory($userCategories, $plaidCategory, string $transactionType): ?Category
105
+    {
106
+        $plaidWords = explode(' ', strtolower($plaidCategory));
107
+
108
+        $bestMatchCategory = null;
109
+        $bestMatchScore = 0; // Higher is better
110
+
111
+        foreach ($userCategories as $category) {
112
+            if (strtolower($category->type->value) !== strtolower($transactionType)) {
113
+                continue;
114
+            }
115
+
116
+            $categoryWords = explode(' ', strtolower($category->name));
117
+            $matchScore = count(array_intersect($plaidWords, $categoryWords));
118
+
119
+            if ($matchScore > $bestMatchScore) {
120
+                $bestMatchScore = $matchScore;
121
+                $bestMatchCategory = $category;
122
+            }
123
+        }
124
+
125
+        return $bestMatchCategory;
126
+    }
127
+
128
+    public function getUncategorizedCategory(Company $company, $transaction, string $transactionType): Category
129
+    {
130
+        $uncategorizedCategoryName = 'Other ' . ucfirst($transactionType);
131
+        $uncategorizedCategory = $company->categories()->where('type', $transactionType)->where('name', $uncategorizedCategoryName)->first();
132
+
133
+        if ($uncategorizedCategory === null) {
134
+            $uncategorizedCategory = $company->categories()->where('type', $transactionType)->where('name', 'Other')->first();
135
+
136
+            if ($uncategorizedCategory === null) {
137
+                $uncategorizedCategory = $company->categories()->where('name', 'Other')->first();
138
+            }
139
+        }
140
+
141
+        return $uncategorizedCategory;
142
+    }
143
+
144
+    public function getChartFromTransaction(Company $company, $transaction, string $transactionType): Chart
145
+    {
146
+        if ($transactionType === 'income') {
147
+            $chart = $company->charts()->where('type', AccountType::OperatingRevenue)->where('name', 'Uncategorized Income')->first();
148
+        } else {
149
+            $chart = $company->charts()->where('type', AccountType::OperatingExpense)->where('name', 'Uncategorized Expense')->first();
150
+        }
151
+
152
+        return $chart;
153
+    }
154
+}

+ 1
- 1
app/Listeners/SyncWithCompanyDefaults.php Bestand weergeven

@@ -60,7 +60,7 @@ class SyncWithCompanyDefaults
60 60
             'Tax' => $this->handleTax($default, $type, $model->getKey()),
61 61
             'Category' => $this->handleCategory($default, $type, $model->getKey()),
62 62
             'Currency' => $default->currency_code = $model->getAttribute('code'),
63
-            'Account' => $default->account_id = $model->getKey(),
63
+            'BankAccount' => $default->bank_account_id = $model->getKey(),
64 64
             default => null,
65 65
         };
66 66
 

+ 220
- 0
app/Livewire/Company/Service/ConnectedAccount/ListInstitutions.php Bestand weergeven

@@ -0,0 +1,220 @@
1
+<?php
2
+
3
+namespace App\Livewire\Company\Service\ConnectedAccount;
4
+
5
+use App\Events\PlaidSuccess;
6
+use App\Events\StartTransactionImport;
7
+use App\Models\Banking\BankAccount;
8
+use App\Models\Banking\ConnectedBankAccount;
9
+use App\Models\Banking\Institution;
10
+use App\Models\User;
11
+use App\Services\PlaidService;
12
+use Filament\Actions\Action;
13
+use Filament\Actions\Concerns\InteractsWithActions;
14
+use Filament\Actions\Contracts\HasActions;
15
+use Filament\Forms\Components\DatePicker;
16
+use Filament\Forms\Components\Select;
17
+use Filament\Forms\Concerns\InteractsWithForms;
18
+use Filament\Forms\Contracts\HasForms;
19
+use Filament\Forms\Form;
20
+use Filament\Forms\Get;
21
+use Filament\Notifications\Notification;
22
+use Filament\Support\Enums\MaxWidth;
23
+use Illuminate\Contracts\View\View;
24
+use Illuminate\Database\Eloquent\Builder;
25
+use Illuminate\Database\Eloquent\Collection;
26
+use Illuminate\Database\Eloquent\Relations\HasMany;
27
+use Illuminate\Support\Facades\Auth;
28
+use Illuminate\Support\Facades\Log;
29
+use JsonException;
30
+use Livewire\Attributes\Computed;
31
+use Livewire\Attributes\On;
32
+use Livewire\Component;
33
+use RuntimeException;
34
+
35
+class ListInstitutions extends Component implements HasForms, HasActions
36
+{
37
+    use InteractsWithForms;
38
+    use InteractsWithActions;
39
+
40
+    protected PlaidService $plaidService;
41
+
42
+    public User $user;
43
+
44
+    public ?ConnectedBankAccount $connectedBankAccount = null;
45
+
46
+    public function boot(PlaidService $plaidService): void
47
+    {
48
+        $this->plaidService = $plaidService;
49
+    }
50
+
51
+    public function mount(): void
52
+    {
53
+        $this->user = Auth::user();
54
+    }
55
+
56
+    #[Computed]
57
+    public function connectedInstitutions(): Collection|array
58
+    {
59
+        return Institution::withWhereHas('connectedBankAccounts')
60
+            ->get();
61
+    }
62
+
63
+    public function startImportingTransactions(): Action
64
+    {
65
+        return Action::make('startImportingTransactions')
66
+            ->link()
67
+            ->icon('heroicon-o-cloud-arrow-down')
68
+            ->label('Start Importing Transactions')
69
+            ->modalWidth(MaxWidth::TwoExtraLarge)
70
+            ->stickyModalHeader()
71
+            ->stickyModalFooter()
72
+            ->record($this->connectedBankAccount)
73
+            ->mountUsing(function (array $arguments, Form $form) {
74
+                $connectedAccountId = $arguments['connectedBankAccount'];
75
+
76
+                $this->connectedBankAccount = ConnectedBankAccount::find($connectedAccountId);
77
+
78
+                $form
79
+                    ->fill($this->connectedBankAccount->toArray())
80
+                    ->operation('edit')
81
+                    ->model($this->connectedBankAccount);
82
+            })
83
+            ->form([
84
+                Select::make('bank_account_id')
85
+                    ->label('Select Account')
86
+                    ->visible(static fn (?ConnectedBankAccount $connectedBankAccount) => $connectedBankAccount?->bank_account_id === null)
87
+                    ->options(fn () => $this->getBankAccountOptions())
88
+                    ->required()
89
+                    ->placeholder('Select an account to start importing transactions for.'),
90
+                DatePicker::make('start_date')
91
+                    ->label('Start Date')
92
+                    ->required()
93
+                    ->placeholder('Select a start date for importing transactions.'),
94
+            ])
95
+            ->action(function (array $arguments, array $data, ConnectedBankAccount $connectedBankAccount) {
96
+                $connectedBankAccountId = $arguments['connectedBankAccount'];
97
+                $selectedBankAccountId = $data['bank_account_id'] ?? $connectedBankAccount->bank_account_id;
98
+                $startDate = $data['start_date'];
99
+                $company = $this->user->currentCompany;
100
+
101
+                StartTransactionImport::dispatch($company, $connectedBankAccountId, $selectedBankAccountId, $startDate);
102
+
103
+                unset($this->connectedInstitutions);
104
+            });
105
+    }
106
+
107
+    public function getBankAccountOptions(): array
108
+    {
109
+        $institutionId = $this->connectedBankAccount->institution_id ?? null;
110
+
111
+        $options = BankAccount::query()
112
+            ->where('company_id', $this->user->currentCompany->id)
113
+            ->when($institutionId, static fn($query) => $query->where('institution_id', $institutionId))
114
+            ->whereDoesntHave('connectedBankAccount')
115
+            ->with('account')
116
+            ->get()
117
+            ->pluck('account.name', 'id')
118
+            ->toArray();
119
+
120
+        return ['new' => 'New Account'] + $options;
121
+    }
122
+
123
+    public function stopImportingTransactions(): Action
124
+    {
125
+        return Action::make('stopImportingTransactions')
126
+            ->link()
127
+            ->icon('heroicon-o-stop-circle')
128
+            ->label('Stop Importing Transactions')
129
+            ->color('danger')
130
+            ->requiresConfirmation()
131
+            ->modalHeading('Stop Importing Transactions')
132
+            ->modalDescription('Importing transactions automatically helps keep your bookkeeping up to date. Are you sure you want to turn this off?')
133
+            ->modalSubmitActionLabel('Turn Off')
134
+            ->modalCancelActionLabel('Keep On')
135
+            ->action(function (array $arguments) {
136
+                $connectedBankAccount = ConnectedBankAccount::find($arguments['connectedBankAccount']);
137
+
138
+                if ($connectedBankAccount) {
139
+                    $connectedBankAccount->update([
140
+                        'import_transactions' => !$connectedBankAccount->import_transactions,
141
+                    ]);
142
+                }
143
+
144
+                unset($this->connectedInstitutions);
145
+            });
146
+    }
147
+
148
+    public function deleteBankConnection(): Action
149
+    {
150
+        return Action::make('deleteBankConnection')
151
+            ->iconButton()
152
+            ->icon('heroicon-o-trash')
153
+            ->requiresConfirmation()
154
+            ->modalHeading('Delete Bank Connection')
155
+            ->modalDescription('Deleting this bank connection will stop the import of transactions for all accounts associated with this bank. Existing transactions will remain unchanged.')
156
+            ->action(function (array $arguments) {
157
+                $institutionId = $arguments['institution'];
158
+
159
+                $institution = Institution::find($institutionId);
160
+
161
+                if ($institution) {
162
+                    $institution->connectedBankAccounts()->delete();
163
+                }
164
+
165
+                unset($this->connectedInstitutions);
166
+            });
167
+    }
168
+
169
+    #[On('createToken')]
170
+    public function createLinkToken(): void
171
+    {
172
+        $company = $this->user->currentCompany;
173
+
174
+        $companyLanguage = $company->locale->language ?? 'en';
175
+        $companyCountry = $company->profile->country ?? 'US';
176
+
177
+        $plaidUser = $this->plaidService->createPlaidUser($company);
178
+
179
+        try {
180
+            $response = $this->plaidService->createToken($companyLanguage, $companyCountry, $plaidUser, ['transactions']);
181
+
182
+            $plaidLinkToken = $response->link_token;
183
+
184
+            $this->dispatch('initializeLink', $plaidLinkToken)->self();
185
+        } catch (RuntimeException) {
186
+            Log::error("Error creating Plaid token.");
187
+
188
+            $this->sendErrorNotification("We're currently experiencing issues connecting your account. Please try again in a few moments.");
189
+        }
190
+    }
191
+
192
+    #[On('linkSuccess')]
193
+    public function handleLinkSuccess($publicToken, $metadata): void
194
+    {
195
+        $response = $this->plaidService->exchangePublicToken($publicToken);
196
+
197
+        $accessToken = $response->access_token;
198
+
199
+        $company = $this->user->currentCompany;
200
+
201
+        PlaidSuccess::dispatch($publicToken, $accessToken, $company);
202
+
203
+        unset($this->connectedInstitutions);
204
+    }
205
+
206
+    public function sendErrorNotification(string $message): void
207
+    {
208
+        Notification::make()
209
+            ->title('Hold On...')
210
+            ->danger()
211
+            ->body($message)
212
+            ->persistent()
213
+            ->send();
214
+    }
215
+
216
+    public function render(): View
217
+    {
218
+        return view('livewire.company.service.connected-account.list-institutions');
219
+    }
220
+}

+ 171
- 0
app/Livewire/UpdatePassword.php Bestand weergeven

@@ -0,0 +1,171 @@
1
+<?php
2
+
3
+namespace App\Livewire;
4
+
5
+use Filament\Facades\Filament;
6
+use Filament\Forms;
7
+use Filament\Forms\Concerns\InteractsWithForms;
8
+use Filament\Forms\Contracts\HasForms;
9
+use Filament\Forms\Form;
10
+use Filament\Notifications\Notification;
11
+use Filament\Support\Exceptions\Halt;
12
+use Illuminate\Contracts\Auth\Authenticatable;
13
+use Illuminate\Database\Eloquent\Model;
14
+use Illuminate\Support\Facades\Hash;
15
+use Illuminate\Validation\Rules\Password;
16
+use Livewire\Component;
17
+use Illuminate\Contracts\View\View;
18
+use RuntimeException;
19
+
20
+/**
21
+ * @property Form $form
22
+ */
23
+class UpdatePassword extends Component implements HasForms
24
+{
25
+    use InteractsWithForms;
26
+
27
+    /**
28
+     * @var array<string, mixed> | null
29
+     */
30
+    public ?array $data = [];
31
+
32
+    public function mount(): void
33
+    {
34
+        $this->fillForm();
35
+    }
36
+
37
+    public function getUser(): Authenticatable|Model
38
+    {
39
+        $user = Filament::auth()->user();
40
+
41
+        if (! $user instanceof Model) {
42
+            throw new RuntimeException('The authenticated user object must be an Eloquent model to allow profile information to be updated.');
43
+        }
44
+
45
+        return $user;
46
+    }
47
+
48
+    public function fillForm(): void
49
+    {
50
+        $data = $this->getUser()->attributesToArray();
51
+
52
+        $data = $this->mutateFormDataBeforeFill($data);
53
+
54
+        $this->form->fill($data);
55
+    }
56
+
57
+    /**
58
+     * @param  array<string, mixed>  $data
59
+     * @return array<string, mixed>
60
+     */
61
+    protected function mutateFormDataBeforeFill(array $data): array
62
+    {
63
+        return $data;
64
+    }
65
+
66
+    /**
67
+     * @param  array<string, mixed>  $data
68
+     * @return array<string, mixed>
69
+     */
70
+    protected function mutateFormDataBeforeSave(array $data): array
71
+    {
72
+        return $data;
73
+    }
74
+
75
+    public function save(): void
76
+    {
77
+        try {
78
+            $data = $this->form->getState();
79
+
80
+            $data = $this->mutateFormDataBeforeSave($data);
81
+
82
+            $this->handleRecordUpdate($this->getUser(), $data);
83
+        } catch (Halt $exception) {
84
+            return;
85
+        }
86
+
87
+        if (session() !== null) {
88
+            session()->put([
89
+                'password_hash_' . Filament::getAuthGuard() => Filament::auth()->user()?->getAuthPassword(),
90
+            ]);
91
+        }
92
+
93
+        $this->getSavedNotification()?->send();
94
+
95
+        $this->fillForm();
96
+    }
97
+
98
+    /**
99
+     * @param  array<string, mixed>  $data
100
+     */
101
+    protected function handleRecordUpdate(Model $record, array $data): Model
102
+    {
103
+        $record->update($data);
104
+
105
+        return $record;
106
+    }
107
+
108
+    protected function getSavedNotification(): ?Notification
109
+    {
110
+        $title = $this->getSavedNotificationTitle();
111
+
112
+        if (blank($title)) {
113
+            return null;
114
+        }
115
+
116
+        return Notification::make()
117
+            ->success()
118
+            ->title($this->getSavedNotificationTitle())
119
+            ->body($this->getSavedNotificationBody());
120
+    }
121
+
122
+    protected function getSavedNotificationTitle(): ?string
123
+    {
124
+        return __('filament-companies::default.notifications.profile_information_updated.title');
125
+    }
126
+
127
+    protected function getSavedNotificationBody(): ?string
128
+    {
129
+        return __('filament-companies::default.notifications.profile_information_updated.body');
130
+    }
131
+
132
+    public function form(Form $form): Form
133
+    {
134
+        return $form
135
+            ->schema([
136
+                Forms\Components\TextInput::make('current_password')
137
+                    ->label(__('filament-companies::default.fields.current_password'))
138
+                    ->password()
139
+                    ->currentPassword()
140
+                    ->revealable()
141
+                    ->validationMessages([
142
+                        'current_password' => __('filament-companies::default.errors.password_does_not_match'),
143
+                    ])
144
+                    ->autocomplete('current-password')
145
+                    ->required(),
146
+                Forms\Components\TextInput::make('password')
147
+                    ->label(__('filament-companies::default.labels.new_password'))
148
+                    ->password()
149
+                    ->autocomplete('new-password')
150
+                    ->rule(Password::default())
151
+                    ->required()
152
+                    ->dehydrated(static fn ($state): bool => filled($state))
153
+                    ->dehydrateStateUsing(static fn ($state): string => Hash::make($state))
154
+                    ->same('password_confirmation'),
155
+                Forms\Components\TextInput::make('password_confirmation')
156
+                    ->label(__('filament-companies::default.labels.password_confirmation'))
157
+                    ->password()
158
+                    ->autocomplete('new-password')
159
+                    ->required()
160
+                    ->dehydrated(false),
161
+            ])
162
+            ->operation('edit')
163
+            ->model($this->getUser())
164
+            ->statePath('data');
165
+    }
166
+
167
+    public function render(): View
168
+    {
169
+        return view('livewire.update-password-form');
170
+    }
171
+}

+ 183
- 0
app/Livewire/UpdateProfileInformation.php Bestand weergeven

@@ -0,0 +1,183 @@
1
+<?php
2
+
3
+namespace App\Livewire;
4
+
5
+use App\Models\User;
6
+use Filament\Facades\Filament;
7
+use Filament\Forms;
8
+use Filament\Forms\Concerns\InteractsWithForms;
9
+use Filament\Forms\Contracts\HasForms;
10
+use Filament\Forms\Form;
11
+use Filament\Notifications\Notification;
12
+use Filament\Support\Exceptions\Halt;
13
+use Illuminate\Contracts\Auth\Authenticatable;
14
+use Illuminate\Database\Eloquent\Model;
15
+use Illuminate\Http\UploadedFile;
16
+use Illuminate\Support\Facades\Blade;
17
+use Illuminate\Support\HtmlString;
18
+use Livewire\Component;
19
+use Illuminate\Contracts\View\View;
20
+use RuntimeException;
21
+use Wallo\FilamentCompanies\Features;
22
+
23
+/**
24
+ * @property Form $form
25
+ */
26
+class UpdateProfileInformation extends Component implements HasForms
27
+{
28
+    use InteractsWithForms;
29
+
30
+    /**
31
+     * @var array<string, mixed> | null
32
+     */
33
+    public ?array $data = [];
34
+
35
+    public function mount(): void
36
+    {
37
+        $this->fillForm();
38
+    }
39
+
40
+    public function getUser(): Authenticatable|Model
41
+    {
42
+        $user = Filament::auth()->user();
43
+
44
+        if (! $user instanceof Model) {
45
+            throw new RuntimeException('The authenticated user object must be an Eloquent model to allow profile information to be updated.');
46
+        }
47
+
48
+        return $user;
49
+    }
50
+
51
+    public function fillForm(): void
52
+    {
53
+        $data = $this->getUser()->withoutRelations()->toArray();
54
+
55
+        $data = $this->mutateFormDataBeforeFill($data);
56
+
57
+        $this->form->fill($data);
58
+    }
59
+
60
+    /**
61
+     * @param  array<string, mixed>  $data
62
+     * @return array<string, mixed>
63
+     */
64
+    protected function mutateFormDataBeforeFill(array $data): array
65
+    {
66
+        return $data;
67
+    }
68
+
69
+    /**
70
+     * @param  array<string, mixed>  $data
71
+     * @return array<string, mixed>
72
+     */
73
+    protected function mutateFormDataBeforeSave(array $data): array
74
+    {
75
+        return $data;
76
+    }
77
+
78
+    public function save(): void
79
+    {
80
+        try {
81
+            $data = $this->form->getState();
82
+
83
+            $data = $this->mutateFormDataBeforeSave($data);
84
+
85
+            $this->handleRecordUpdate($this->getUser(), $data);
86
+        } catch (Halt $exception) {
87
+            return;
88
+        }
89
+
90
+        $this->getSavedNotification()?->send();
91
+
92
+        $this->fillForm();
93
+    }
94
+
95
+    /**
96
+     * @param  array<string, mixed>  $data
97
+     */
98
+    protected function handleRecordUpdate(Model $record, array $data): Model
99
+    {
100
+        $record->update($data);
101
+
102
+        return $record;
103
+    }
104
+
105
+    protected function getSavedNotification(): ?Notification
106
+    {
107
+        $title = $this->getSavedNotificationTitle();
108
+
109
+        if (blank($title)) {
110
+            return null;
111
+        }
112
+
113
+        return Notification::make()
114
+            ->success()
115
+            ->title($this->getSavedNotificationTitle())
116
+            ->body($this->getSavedNotificationBody());
117
+    }
118
+
119
+    protected function getSavedNotificationTitle(): ?string
120
+    {
121
+        return __('filament-companies::default.notifications.profile_information_updated.title');
122
+    }
123
+
124
+    protected function getSavedNotificationBody(): ?string
125
+    {
126
+        return __('filament-companies::default.notifications.profile_information_updated.body');
127
+    }
128
+
129
+    public function form(Form $form): Form
130
+    {
131
+        return $form
132
+            ->schema([
133
+                Forms\Components\FileUpload::make('profile_photo_path')
134
+                    ->label('Photo')
135
+                    ->avatar()
136
+                    ->extraAttributes([
137
+                        'style' => 'width: 6rem; height: 6rem;',
138
+                    ])
139
+                    ->placeholder(static function () {
140
+                        return new HtmlString('
141
+                            <div style="display: inline-block; cursor: pointer;">
142
+                                <div class="flex items-center justify-center bg-gray-50 dark:bg-gray-800" style="
143
+                                    border-radius: 50%;
144
+                                    width: 50px;
145
+                                    height: 50px;">
146
+                                   ' . Blade::render('<x-heroicon-o-camera class="w-8 h-8 text-gray-800 dark:text-gray-300" />') . '
147
+                                </div>
148
+                            </div>
149
+                        ');
150
+                    })
151
+                    ->disk(Features::profilePhotoDisk())
152
+                    ->directory(Features::profilePhotoStoragePath())
153
+                    ->saveUploadedFileUsing(function (User $record, UploadedFile $file) {
154
+                        $record->updateProfilePhoto($file);
155
+                    })
156
+                    ->deleteUploadedFileUsing(function (User $record) {
157
+                        $record->deleteProfilePhoto();
158
+                    })
159
+                    ->image()
160
+                    ->nullable(),
161
+                Forms\Components\TextInput::make('name')
162
+                    ->label(__('Name'))
163
+                    ->required()
164
+                    ->maxLength(255)
165
+                    ->autofocus(),
166
+                Forms\Components\TextInput::make('email')
167
+                    ->label(__('Email'))
168
+                    ->email()
169
+                    ->required()
170
+                    ->autocomplete('username')
171
+                    ->maxLength(255)
172
+                    ->unique(ignoreRecord: true),
173
+            ])
174
+            ->operation('edit')
175
+            ->model($this->getUser())
176
+            ->statePath('data');
177
+    }
178
+
179
+    public function render(): View
180
+    {
181
+        return view('livewire.update-profile-information');
182
+    }
183
+}

+ 126
- 0
app/Models/Accounting/Account.php Bestand weergeven

@@ -0,0 +1,126 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Models\Setting\Category;
7
+use App\Models\Setting\Currency;
8
+use App\Traits\Blamable;
9
+use App\Traits\CompanyOwned;
10
+use Database\Factories\Accounting\AccountFactory;
11
+use Illuminate\Database\Eloquent\Factories\Factory;
12
+use Illuminate\Database\Eloquent\Factories\HasFactory;
13
+use Illuminate\Database\Eloquent\Model;
14
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
15
+use Illuminate\Database\Eloquent\Relations\HasMany;
16
+use Illuminate\Database\Eloquent\Relations\HasManyThrough;
17
+use Illuminate\Database\Eloquent\Relations\MorphTo;
18
+use Wallo\FilamentCompanies\FilamentCompanies;
19
+
20
+class Account extends Model
21
+{
22
+    use Blamable;
23
+    use CompanyOwned;
24
+    use HasFactory;
25
+
26
+    protected $table = 'accounts';
27
+
28
+    protected $fillable = [
29
+        'company_id',
30
+        'subtype_id',
31
+        'parent_id',
32
+        'category',
33
+        'type',
34
+        'code',
35
+        'name',
36
+        'currency_code',
37
+        'starting_balance',
38
+        'debit_balance',
39
+        'credit_balance',
40
+        'ending_balance',
41
+        'description',
42
+        'active',
43
+        'default',
44
+        'accountable_id',
45
+        'accountable_type',
46
+        'created_by',
47
+        'updated_by',
48
+    ];
49
+
50
+    protected $casts = [
51
+        'starting_balance' => MoneyCast::class,
52
+        'debit_balance' => MoneyCast::class,
53
+        'credit_balance' => MoneyCast::class,
54
+        'ending_balance' => MoneyCast::class,
55
+        'active' => 'boolean',
56
+        'default' => 'boolean',
57
+    ];
58
+
59
+    public function company(): BelongsTo
60
+    {
61
+        return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
62
+    }
63
+
64
+    public function categories(): HasMany
65
+    {
66
+        return $this->hasMany(Category::class, 'account_id');
67
+    }
68
+
69
+    public function subtype(): BelongsTo
70
+    {
71
+        return $this->belongsTo(AccountSubtype::class, 'subtype_id');
72
+    }
73
+
74
+    public function parent(): BelongsTo
75
+    {
76
+        return $this->belongsTo(__CLASS__, 'parent_id')
77
+            ->whereKeyNot($this->getKey());
78
+    }
79
+
80
+    public function children(): HasMany
81
+    {
82
+        return $this->hasMany(__CLASS__, 'parent_id');
83
+    }
84
+
85
+    public function currency(): BelongsTo
86
+    {
87
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
88
+    }
89
+
90
+    public function createdBy(): BelongsTo
91
+    {
92
+        return $this->belongsTo(FilamentCompanies::userModel(), 'created_by');
93
+    }
94
+
95
+    public function updatedBy(): BelongsTo
96
+    {
97
+        return $this->belongsTo(FilamentCompanies::userModel(), 'updated_by');
98
+    }
99
+
100
+    public function accountable(): MorphTo
101
+    {
102
+        return $this->morphTo();
103
+    }
104
+
105
+    public function transactions(): HasManyThrough
106
+    {
107
+        return $this->hasManyThrough(
108
+            Transaction::class,
109
+            JournalEntry::class,
110
+            'account_id',
111
+            'id',
112
+            'id',
113
+            'transaction_id',
114
+        );
115
+    }
116
+
117
+    public function journalEntries(): HasMany
118
+    {
119
+        return $this->hasMany(JournalEntry::class, 'account_id');
120
+    }
121
+
122
+    protected static function newFactory(): Factory
123
+    {
124
+        return AccountFactory::new();
125
+    }
126
+}

+ 53
- 0
app/Models/Accounting/AccountSubtype.php Bestand weergeven

@@ -0,0 +1,53 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Enums\Accounting\AccountCategory;
6
+use App\Enums\Accounting\AccountType;
7
+use App\Traits\CompanyOwned;
8
+use Database\Factories\Accounting\AccountSubtypeFactory;
9
+use Illuminate\Database\Eloquent\Factories\Factory;
10
+use Illuminate\Database\Eloquent\Factories\HasFactory;
11
+use Illuminate\Database\Eloquent\Model;
12
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
13
+use Illuminate\Database\Eloquent\Relations\HasMany;
14
+use Wallo\FilamentCompanies\FilamentCompanies;
15
+
16
+class AccountSubtype extends Model
17
+{
18
+    use HasFactory;
19
+    use CompanyOwned;
20
+
21
+    protected $table = 'account_subtypes';
22
+
23
+    protected $fillable = [
24
+        'company_id',
25
+        'multi_currency',
26
+        'category',
27
+        'type',
28
+        'name',
29
+        'description',
30
+    ];
31
+
32
+    protected $casts = [
33
+        'multi_currency' => 'boolean',
34
+        'category' => AccountCategory::class,
35
+        'type' => AccountType::class,
36
+    ];
37
+
38
+
39
+    public function company(): BelongsTo
40
+    {
41
+        return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
42
+    }
43
+
44
+    public function accounts(): HasMany
45
+    {
46
+        return $this->hasMany(Account::class, 'subtype_id');
47
+    }
48
+
49
+    protected static function newFactory(): Factory
50
+    {
51
+        return AccountSubtypeFactory::new();
52
+    }
53
+}

+ 75
- 0
app/Models/Accounting/JournalEntry.php Bestand weergeven

@@ -0,0 +1,75 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Traits\Blamable;
7
+use App\Traits\CompanyOwned;
8
+use Database\Factories\Accounting\JournalEntryFactory;
9
+use Illuminate\Database\Eloquent\Factories\Factory;
10
+use Illuminate\Database\Eloquent\Factories\HasFactory;
11
+use Illuminate\Database\Eloquent\Model;
12
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
13
+use Wallo\FilamentCompanies\FilamentCompanies;
14
+
15
+class JournalEntry extends Model
16
+{
17
+    use Blamable;
18
+    use CompanyOwned;
19
+    use HasFactory;
20
+
21
+    protected $fillable = [
22
+        'company_id',
23
+        'account_id',
24
+        'transaction_id',
25
+        'type', // debit or credit
26
+        'amount',
27
+        'description',
28
+        'created_by',
29
+        'updated_by',
30
+    ];
31
+
32
+    protected $casts = [
33
+        'amount' => MoneyCast::class,
34
+    ];
35
+
36
+    public function company(): BelongsTo
37
+    {
38
+        return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
39
+    }
40
+
41
+    public function account(): BelongsTo
42
+    {
43
+        return $this->belongsTo(Account::class, 'account_id');
44
+    }
45
+
46
+    public function transaction(): BelongsTo
47
+    {
48
+        return $this->belongsTo(Transaction::class, 'transaction_id');
49
+    }
50
+
51
+    public function scopeDebit($query)
52
+    {
53
+        return $query->where('type', 'debit');
54
+    }
55
+
56
+    public function scopeCredit($query)
57
+    {
58
+        return $query->where('type', 'credit');
59
+    }
60
+
61
+    public function createdBy(): BelongsTo
62
+    {
63
+        return $this->belongsTo(FilamentCompanies::userModel(), 'created_by');
64
+    }
65
+
66
+    public function updatedBy(): BelongsTo
67
+    {
68
+        return $this->belongsTo(FilamentCompanies::userModel(), 'updated_by');
69
+    }
70
+
71
+    protected static function newFactory(): Factory
72
+    {
73
+        return JournalEntryFactory::new();
74
+    }
75
+}

+ 83
- 0
app/Models/Accounting/Transaction.php Bestand weergeven

@@ -0,0 +1,83 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Models\Common\Contact;
7
+use App\Models\Setting\Category;
8
+use App\Traits\Blamable;
9
+use App\Traits\CompanyOwned;
10
+use Database\Factories\Accounting\TransactionFactory;
11
+use Illuminate\Database\Eloquent\Factories\Factory;
12
+use Illuminate\Database\Eloquent\Factories\HasFactory;
13
+use Illuminate\Database\Eloquent\Model;
14
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
15
+use Illuminate\Database\Eloquent\Relations\HasMany;
16
+use Wallo\FilamentCompanies\FilamentCompanies;
17
+
18
+class Transaction extends Model
19
+{
20
+    use Blamable;
21
+    use CompanyOwned;
22
+    use HasFactory;
23
+
24
+    protected $fillable = [
25
+        'company_id',
26
+        'category_id',
27
+        'contact_id',
28
+        'type',
29
+        'method',
30
+        'payment_channel',
31
+        'description',
32
+        'notes',
33
+        'reference',
34
+        'amount',
35
+        'pending',
36
+        'reviewed',
37
+        'posted_at',
38
+        'created_by',
39
+        'updated_by',
40
+    ];
41
+
42
+    protected $casts = [
43
+        'amount' => MoneyCast::class,
44
+        'pending' => 'boolean',
45
+        'reviewed' => 'boolean',
46
+        'posted_at' => 'datetime',
47
+    ];
48
+
49
+    public function company(): BelongsTo
50
+    {
51
+        return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
52
+    }
53
+
54
+    public function category(): BelongsTo
55
+    {
56
+        return $this->belongsTo(Category::class, 'category_id');
57
+    }
58
+
59
+    public function contact(): BelongsTo
60
+    {
61
+        return $this->belongsTo(Contact::class, 'contact_id');
62
+    }
63
+
64
+    public function journalEntries(): HasMany
65
+    {
66
+        return $this->hasMany(JournalEntry::class, 'transaction_id');
67
+    }
68
+
69
+    public function createdBy(): BelongsTo
70
+    {
71
+        return $this->belongsTo(FilamentCompanies::userModel(), 'created_by');
72
+    }
73
+
74
+    public function updatedBy(): BelongsTo
75
+    {
76
+        return $this->belongsTo(FilamentCompanies::userModel(), 'updated_by');
77
+    }
78
+
79
+    protected static function newFactory(): Factory
80
+    {
81
+        return TransactionFactory::new();
82
+    }
83
+}

app/Models/Banking/Account.php → app/Models/Banking/BankAccount.php Bestand weergeven

@@ -2,65 +2,50 @@
2 2
 
3 3
 namespace App\Models\Banking;
4 4
 
5
-use App\Casts\MoneyCast;
6
-use App\Enums\AccountStatus;
7
-use App\Enums\AccountType;
8
-use App\Models\History\AccountHistory;
9
-use App\Models\Setting\Currency;
5
+use App\Enums\BankAccountSubtype;
6
+use App\Enums\BankAccountType;
7
+use App\Models\Accounting\Account;
10 8
 use App\Traits\Blamable;
11 9
 use App\Traits\CompanyOwned;
12 10
 use App\Traits\HasDefault;
13 11
 use App\Traits\SyncsWithCompanyDefaults;
14
-use Database\Factories\Banking\AccountFactory;
12
+use Database\Factories\Banking\BankAccountFactory;
13
+use Illuminate\Database\Eloquent\Casts\Attribute;
15 14
 use Illuminate\Database\Eloquent\Factories\Factory;
16 15
 use Illuminate\Database\Eloquent\Factories\HasFactory;
17 16
 use Illuminate\Database\Eloquent\Model;
18 17
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
-use Illuminate\Database\Eloquent\Relations\HasMany;
20
-use Spatie\Tags\HasTags;
18
+use Illuminate\Database\Eloquent\Relations\HasOne;
19
+use Illuminate\Database\Eloquent\Relations\MorphOne;
21 20
 use Wallo\FilamentCompanies\FilamentCompanies;
22 21
 
23
-class Account extends Model
22
+class BankAccount extends Model
24 23
 {
25 24
     use Blamable;
26 25
     use CompanyOwned;
27 26
     use HasDefault;
28 27
     use HasFactory;
29
-    use HasTags;
30 28
     use SyncsWithCompanyDefaults;
31 29
 
32
-    protected $table = 'accounts';
30
+    protected $table = 'bank_accounts';
33 31
 
34 32
     protected $fillable = [
35 33
         'company_id',
34
+        'institution_id',
36 35
         'type',
37
-        'name',
38 36
         'number',
39
-        'currency_code',
40
-        'opening_balance',
41
-        'balance',
42
-        'description',
43
-        'notes',
44
-        'status',
45
-        'bank_name',
46
-        'bank_phone',
47
-        'bank_address',
48
-        'bank_website',
49
-        'bic_swift_code',
50
-        'iban',
51
-        'aba_routing_number',
52
-        'ach_routing_number',
53 37
         'enabled',
54 38
         'created_by',
55 39
         'updated_by',
56 40
     ];
57 41
 
58 42
     protected $casts = [
59
-        'type' => AccountType::class,
60
-        'status' => AccountStatus::class,
43
+        'type' => BankAccountType::class,
61 44
         'enabled' => 'boolean',
62
-        'opening_balance' => MoneyCast::class,
63
-        'balance' => MoneyCast::class,
45
+    ];
46
+
47
+    protected $appends = [
48
+        'mask',
64 49
     ];
65 50
 
66 51
     public function company(): BelongsTo
@@ -68,9 +53,19 @@ class Account extends Model
68 53
         return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
69 54
     }
70 55
 
71
-    public function currency(): BelongsTo
56
+    public function connectedBankAccount(): HasOne
57
+    {
58
+        return $this->hasOne(ConnectedBankAccount::class, 'bank_account_id');
59
+    }
60
+
61
+    public function account(): MorphOne
62
+    {
63
+        return $this->morphOne(Account::class, 'accountable');
64
+    }
65
+
66
+    public function institution(): BelongsTo
72 67
     {
73
-        return $this->belongsTo(Currency::class, 'currency_code', 'code');
68
+        return $this->belongsTo(Institution::class, 'institution_id');
74 69
     }
75 70
 
76 71
     public function createdBy(): BelongsTo
@@ -83,13 +78,15 @@ class Account extends Model
83 78
         return $this->belongsTo(FilamentCompanies::userModel(), 'updated_by');
84 79
     }
85 80
 
86
-    public function histories(): HasMany
81
+    protected function mask(): Attribute
87 82
     {
88
-        return $this->hasMany(AccountHistory::class, 'account_id');
83
+        return Attribute::get(static function (mixed $value, array $attributes): ?string {
84
+            return $attributes['number'] ? '****' . substr($attributes['number'], -4) : null;
85
+        });
89 86
     }
90 87
 
91 88
     protected static function newFactory(): Factory
92 89
     {
93
-        return AccountFactory::new();
90
+        return BankAccountFactory::new();
94 91
     }
95 92
 }

+ 78
- 0
app/Models/Banking/ConnectedBankAccount.php Bestand weergeven

@@ -0,0 +1,78 @@
1
+<?php
2
+
3
+namespace App\Models\Banking;
4
+
5
+use App\Enums\BankAccountType;
6
+use App\Traits\Blamable;
7
+use App\Traits\CompanyOwned;
8
+use Illuminate\Database\Eloquent\Casts\Attribute;
9
+use Illuminate\Database\Eloquent\Model;
10
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
11
+use Wallo\FilamentCompanies\FilamentCompanies;
12
+
13
+class ConnectedBankAccount extends Model
14
+{
15
+    use Blamable;
16
+    use CompanyOwned;
17
+
18
+    protected $table = 'connected_bank_accounts';
19
+
20
+    protected $fillable = [
21
+        'company_id',
22
+        'institution_id',
23
+        'bank_account_id',
24
+        'external_account_id',
25
+        'access_token',
26
+        'identifier',
27
+        'item_id',
28
+        'name',
29
+        'mask',
30
+        'type',
31
+        'subtype',
32
+        'import_transactions',
33
+        'created_by',
34
+        'updated_by',
35
+    ];
36
+
37
+    protected $casts = [
38
+        'import_transactions' => 'boolean',
39
+        'type' => BankAccountType::class,
40
+        'access_token' => 'encrypted',
41
+    ];
42
+
43
+    protected $appends = [
44
+        'masked_number',
45
+    ];
46
+
47
+    public function company(): BelongsTo
48
+    {
49
+        return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
50
+    }
51
+
52
+    public function institution(): BelongsTo
53
+    {
54
+        return $this->belongsTo(Institution::class, 'institution_id');
55
+    }
56
+
57
+    public function bankAccount(): BelongsTo
58
+    {
59
+        return $this->belongsTo(BankAccount::class, 'bank_account_id');
60
+    }
61
+
62
+    protected function maskedNumber(): Attribute
63
+    {
64
+        return Attribute::get(static function (mixed $value, array $attributes): ?string {
65
+            return $attributes['mask'] ? '****' . $attributes['mask'] : null;
66
+        });
67
+    }
68
+
69
+    public function createdBy(): BelongsTo
70
+    {
71
+        return $this->belongsTo(FilamentCompanies::userModel(), 'created_by');
72
+    }
73
+
74
+    public function updatedBy(): BelongsTo
75
+    {
76
+        return $this->belongsTo(FilamentCompanies::userModel(), 'updated_by');
77
+    }
78
+}

+ 67
- 0
app/Models/Banking/Institution.php Bestand weergeven

@@ -0,0 +1,67 @@
1
+<?php
2
+
3
+namespace App\Models\Banking;
4
+
5
+use Illuminate\Database\Eloquent\Casts\Attribute;
6
+use Illuminate\Database\Eloquent\Factories\HasFactory;
7
+use Illuminate\Database\Eloquent\Model;
8
+use Illuminate\Database\Eloquent\Relations\HasMany;
9
+use Illuminate\Support\Facades\Storage;
10
+
11
+class Institution extends Model
12
+{
13
+    use HasFactory;
14
+
15
+    protected $table = 'institutions';
16
+
17
+    protected $fillable = [
18
+        'external_institution_id',
19
+        'name',
20
+        'logo',
21
+        'website',
22
+        'phone',
23
+        'address',
24
+        'created_by',
25
+        'updated_by',
26
+    ];
27
+
28
+    protected $appends = [
29
+        'logo_url',
30
+    ];
31
+
32
+    public function bankAccounts(): HasMany
33
+    {
34
+        return $this->hasMany(BankAccount::class, 'institution_id');
35
+    }
36
+
37
+    public function connectedBankAccounts(): HasMany
38
+    {
39
+        return $this->hasMany(ConnectedBankAccount::class, 'institution_id');
40
+    }
41
+
42
+    protected function logoUrl(): Attribute
43
+    {
44
+        return Attribute::get(static function (mixed $value, array $attributes): ?string {
45
+            if ($attributes['logo']) {
46
+                return Storage::disk('public')->url($attributes['logo']);
47
+            }
48
+
49
+            return null;
50
+        });
51
+    }
52
+
53
+    public function logo(): Attribute
54
+    {
55
+        return Attribute::set(static function (mixed $value): ?string {
56
+            if ($value) {
57
+                $decoded = base64_decode($value);
58
+                $filename = 'institution_logo_' . uniqid('', true) . '.png';
59
+                Storage::disk('public')->put($filename, $decoded);
60
+
61
+                return $filename;
62
+            }
63
+
64
+            return null;
65
+        });
66
+    }
67
+}

+ 20
- 2
app/Models/Company.php Bestand weergeven

@@ -3,7 +3,9 @@
3 3
 namespace App\Models;
4 4
 
5 5
 use App\Enums\DocumentType;
6
-use App\Models\Banking\Account;
6
+use App\Models\Accounting\AccountSubtype;
7
+use App\Models\Banking\BankAccount;
8
+use App\Models\Banking\ConnectedBankAccount;
7 9
 use App\Models\Common\Contact;
8 10
 use App\Models\Core\Department;
9 11
 use App\Models\History\AccountHistory;
@@ -64,9 +66,14 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
64 66
         return $this->profile->logo_url ?? $this->owner->profile_photo_url;
65 67
     }
66 68
 
69
+    public function connectedBankAccounts(): HasMany
70
+    {
71
+        return $this->hasMany(ConnectedBankAccount::class, 'company_id');
72
+    }
73
+
67 74
     public function accounts(): HasMany
68 75
     {
69
-        return $this->hasMany(Account::class, 'company_id');
76
+        return $this->hasMany(Accounting\Account::class, 'company_id');
70 77
     }
71 78
 
72 79
     public function accountHistories(): HasMany
@@ -74,6 +81,11 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
74 81
         return $this->hasMany(AccountHistory::class, 'company_id');
75 82
     }
76 83
 
84
+    public function bankAccounts(): HasMany
85
+    {
86
+        return $this->hasMany(BankAccount::class, 'company_id');
87
+    }
88
+
77 89
     public function appearance(): HasOne
78 90
     {
79 91
         return $this->hasOne(Appearance::class, 'company_id');
@@ -84,6 +96,12 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
84 96
         return $this->hasMany(Category::class, 'company_id');
85 97
     }
86 98
 
99
+    public function accountSubtypes(): HasMany
100
+    {
101
+        return $this->hasMany(AccountSubtype::class, 'company_id');
102
+
103
+    }
104
+
87 105
     public function contacts(): HasMany
88 106
     {
89 107
         return $this->hasMany(Contact::class, 'company_id');

+ 7
- 0
app/Models/Setting/Category.php Bestand weergeven

@@ -3,6 +3,7 @@
3 3
 namespace App\Models\Setting;
4 4
 
5 5
 use App\Enums\CategoryType;
6
+use App\Models\Accounting\Account;
6 7
 use App\Traits\Blamable;
7 8
 use App\Traits\CompanyOwned;
8 9
 use App\Traits\HasDefault;
@@ -27,6 +28,7 @@ class Category extends Model
27 28
 
28 29
     protected $fillable = [
29 30
         'company_id',
31
+        'account_id',
30 32
         'name',
31 33
         'type',
32 34
         'color',
@@ -45,6 +47,11 @@ class Category extends Model
45 47
         return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
46 48
     }
47 49
 
50
+    public function account(): BelongsTo
51
+    {
52
+        return $this->belongsTo(Account::class, 'account_id');
53
+    }
54
+
48 55
     public function defaultIncomeCategory(): HasOne
49 56
     {
50 57
         return $this->hasOne(CompanyDefault::class, 'income_category_id');

+ 4
- 4
app/Models/Setting/CompanyDefault.php Bestand weergeven

@@ -5,7 +5,7 @@ namespace App\Models\Setting;
5 5
 use App\Enums\CategoryType;
6 6
 use App\Enums\DiscountType;
7 7
 use App\Enums\TaxType;
8
-use App\Models\Banking\Account;
8
+use App\Models\Banking\BankAccount;
9 9
 use App\Traits\Blamable;
10 10
 use App\Traits\CompanyOwned;
11 11
 use Database\Factories\Setting\CompanyDefaultFactory;
@@ -25,7 +25,7 @@ class CompanyDefault extends Model
25 25
 
26 26
     protected $fillable = [
27 27
         'company_id',
28
-        'account_id',
28
+        'bank_account_id',
29 29
         'currency_code',
30 30
         'sales_tax_id',
31 31
         'purchase_tax_id',
@@ -42,9 +42,9 @@ class CompanyDefault extends Model
42 42
         return $this->belongsTo(FilamentCompanies::companyModel(), 'company_id');
43 43
     }
44 44
 
45
-    public function account(): BelongsTo
45
+    public function bankAccount(): BelongsTo
46 46
     {
47
-        return $this->belongsTo(Account::class, 'account_id');
47
+        return $this->belongsTo(BankAccount::class, 'bank_account_id');
48 48
     }
49 49
 
50 50
     public function currency(): BelongsTo

+ 5
- 1
app/Models/Setting/CompanyProfile.php Bestand weergeven

@@ -52,7 +52,11 @@ class CompanyProfile extends Model
52 52
     protected function logoUrl(): Attribute
53 53
     {
54 54
         return Attribute::get(static function (mixed $value, array $attributes): ?string {
55
-            return $attributes['logo'] ? Storage::disk('public')->url($attributes['logo']) : null;
55
+            if ($attributes['logo']) {
56
+                return Storage::disk('public')->url($attributes['logo']);
57
+            }
58
+
59
+            return null;
56 60
         });
57 61
     }
58 62
 

+ 1
- 1
app/Models/Setting/Currency.php Bestand weergeven

@@ -4,7 +4,7 @@ namespace App\Models\Setting;
4 4
 
5 5
 use App\Casts\CurrencyRateCast;
6 6
 use App\Facades\Forex;
7
-use App\Models\Banking\Account;
7
+use App\Models\Accounting\Account;
8 8
 use App\Models\History\AccountHistory;
9 9
 use App\Traits\Blamable;
10 10
 use App\Traits\CompanyOwned;

+ 2
- 0
app/Models/Setting/Localization.php Bestand weergeven

@@ -8,6 +8,7 @@ use App\Enums\TimeFormat;
8 8
 use App\Enums\WeekStart;
9 9
 use App\Traits\Blamable;
10 10
 use App\Traits\CompanyOwned;
11
+use Carbon\Carbon;
11 12
 use Database\Factories\Setting\LocalizationFactory;
12 13
 use Illuminate\Database\Eloquent\Factories\Factory;
13 14
 use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -78,6 +79,7 @@ class Localization extends Model
78 79
 
79 80
     public static function getWeekStart(string $locale): int
80 81
     {
82
+        /** @var Carbon $date */
81 83
         $date = now()->locale($locale);
82 84
 
83 85
         $firstDay = $date->startOfWeek()->dayOfWeekIso;

+ 1
- 1
app/Models/User.php Bestand weergeven

@@ -84,7 +84,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasDefaul
84 84
 
85 85
     public function getDefaultTenant(Panel $panel): ?Model
86 86
     {
87
-        return $this->currentCompany;
87
+        return $this->personalCompany();
88 88
     }
89 89
 
90 90
     public function getFilamentAvatarUrl(): string

+ 135
- 50
app/Observers/AccountObserver.php Bestand weergeven

@@ -2,24 +2,51 @@
2 2
 
3 3
 namespace App\Observers;
4 4
 
5
+use App\Enums\Accounting\AccountCategory;
6
+use App\Enums\Accounting\AccountType;
5 7
 use App\Enums\AccountStatus;
6
-use App\Models\Banking\Account;
8
+use App\Models\Accounting\Account;
9
+use App\Models\Accounting\AccountSubtype;
10
+use App\Models\Banking\BankAccount;
11
+use App\Utilities\Accounting\AccountCode;
7 12
 
8 13
 class AccountObserver
9 14
 {
10
-    protected array $actions = [
11
-        'exchange_rate_changed' => 'balance',
12
-        'currency_changed' => 'currency_code',
13
-        'status_changed' => 'status',
14
-        'default_account_changed' => 'enabled',
15
-        'type_changed' => 'type',
16
-        'name_changed' => 'name',
17
-        'number_changed' => 'number',
18
-    ];
19
-
20 15
     public function creating(Account $account): void
21 16
     {
22
-        $account->balance = $account->opening_balance;
17
+        $this->setCategoryAndType($account, true);
18
+
19
+        // $bankAccount = $account->accountable;
20
+        if (($account->accountable_type === BankAccount::class) && $account->code === null) {
21
+            $this->setFieldsForBankAccount($account);
22
+        }
23
+    }
24
+
25
+    public function updating(Account $account): void
26
+    {
27
+        if ($account->isDirty('subtype_id')) {
28
+            $this->setCategoryAndType($account, false);
29
+        }
30
+    }
31
+
32
+    private function setCategoryAndType(Account $account, bool $isCreating): void
33
+    {
34
+        $subtype = $account->subtype_id ? AccountSubtype::find($account->subtype_id) : null;
35
+
36
+        if ($subtype) {
37
+            $account->category = $subtype->category;
38
+            $account->type = $subtype->type;
39
+        } elseif ($isCreating) {
40
+            $account->category = AccountCategory::Asset;
41
+            $account->type = AccountType::CurrentAsset;
42
+        }
43
+    }
44
+
45
+    private function setFieldsForBankAccount(Account $account): void
46
+    {
47
+        $generatedAccountCode = AccountCode::generate($account->company_id, $account->subtype_id);
48
+
49
+        $account->code = $generatedAccountCode;
23 50
     }
24 51
 
25 52
     /**
@@ -27,21 +54,21 @@ class AccountObserver
27 54
      */
28 55
     public function created(Account $account): void
29 56
     {
30
-        $account->histories()->create([
31
-            'company_id' => $account->company_id,
32
-            'account_id' => $account->id,
33
-            'type' => $account->type,
34
-            'name' => $account->name,
35
-            'number' => $account->number,
36
-            'currency_code' => $account->currency_code,
37
-            'opening_balance' => $account->opening_balance,
38
-            'balance' => $account->balance,
39
-            'exchange_rate' => $account->currency->rate,
40
-            'status' => AccountStatus::Open,
41
-            'actions' => ['account_created'],
42
-            'enabled' => $account->enabled,
43
-            'changed_by' => $account->created_by,
44
-        ]);
57
+        //$account->histories()->create([
58
+           // 'company_id' => $account->company_id,
59
+          //  'account_id' => $account->id,
60
+           // 'type' => $account->type,
61
+          //  'name' => $account->name,
62
+          //  'number' => $account->number,
63
+           // 'currency_code' => $account->currency_code,
64
+         //   'opening_balance' => $account->opening_balance,
65
+         //   'balance' => $account->balance,
66
+         //   'exchange_rate' => $account->currency->rate,
67
+          //  'status' => AccountStatus::Open,
68
+          //  'actions' => ['account_created'],
69
+         //   'enabled' => $account->enabled,
70
+          //  'changed_by' => $account->created_by,
71
+        //]);
45 72
     }
46 73
 
47 74
     /**
@@ -49,31 +76,31 @@ class AccountObserver
49 76
      */
50 77
     public function updated(Account $account): void
51 78
     {
52
-        $actionsTaken = [];
79
+        //$actionsTaken = [];
53 80
 
54
-        foreach ($this->actions as $action => $attribute) {
55
-            if ($account->isDirty($attribute)) {
56
-                $actionsTaken[] = $action;
57
-            }
58
-        }
81
+        //foreach ($this->actions as $action => $attribute) {
82
+            //if ($account->isDirty($attribute)) {
83
+                //$actionsTaken[] = $action;
84
+           // }
85
+        //}
59 86
 
60
-        if (count($actionsTaken) > 0) {
61
-            $account->histories()->create([
62
-                'company_id' => $account->company_id,
63
-                'account_id' => $account->id,
64
-                'type' => $account->getOriginal('type'),
65
-                'name' => $account->getOriginal('name'),
66
-                'number' => $account->getOriginal('number'),
67
-                'currency_code' => $account->getOriginal('currency_code'),
68
-                'opening_balance' => $account->getRawOriginal('opening_balance'),
69
-                'balance' => $account->getRawOriginal('balance'),
70
-                'exchange_rate' => $account->currency->getRawOriginal('rate'),
71
-                'status' => $account->getOriginal('status'),
72
-                'actions' => $actionsTaken,
73
-                'enabled' => $account->getOriginal('enabled'),
74
-                'changed_by' => $account->updated_by,
75
-            ]);
76
-        }
87
+        //if (count($actionsTaken) > 0) {
88
+            //$account->histories()->create([
89
+                //'company_id' => $account->company_id,
90
+               // 'account_id' => $account->id,
91
+               // 'type' => $account->getOriginal('type'),
92
+               // 'name' => $account->getOriginal('name'),
93
+               // 'number' => $account->getOriginal('number'),
94
+               // 'currency_code' => $account->getOriginal('currency_code'),
95
+               // 'opening_balance' => $account->getRawOriginal('opening_balance'),
96
+               // 'balance' => $account->getRawOriginal('balance'),
97
+               // 'exchange_rate' => $account->currency->getRawOriginal('rate'),
98
+               // 'status' => $account->getOriginal('status'),
99
+               // 'actions' => $actionsTaken,
100
+               // 'enabled' => $account->getOriginal('enabled'),
101
+               // 'changed_by' => $account->updated_by,
102
+            //]);
103
+        //}
77 104
     }
78 105
 
79 106
     /**
@@ -99,4 +126,62 @@ class AccountObserver
99 126
     {
100 127
         //
101 128
     }
129
+
130
+    private function getDefaultChartForBankAccount(Account $account): Account
131
+    {
132
+        $defaultChartCategory = AccountCategory::Asset;
133
+        $defaultChartType = AccountType::CurrentAsset;
134
+
135
+        //if ($account->type->isCreditCard()) {
136
+            //$defaultChartCategory = ChartCategory::Liability;
137
+            //$defaultChartType = ChartType::CurrentLiability;
138
+        //}
139
+
140
+        $subTypeId = $this->getSubTypeId($account->company_id, $defaultChartType);
141
+
142
+        $latestChartCode = Account::where('company_id', $account->company_id)
143
+            ->where('category', $defaultChartCategory)
144
+            ->where('type', $defaultChartType)
145
+            ->max('code');
146
+
147
+        $newChartCode = $latestChartCode ? ++$latestChartCode : '1000';
148
+
149
+        return Account::create([
150
+            'company_id' => $account->company_id,
151
+            'category' => $defaultChartCategory,
152
+            'type' => $defaultChartType,
153
+            'subtype_id' => $subTypeId,
154
+            'code' => $newChartCode,
155
+            'name' => $account->name,
156
+            'currency_code' => $account->currency_code,
157
+            'description' => $account->description ?? $account->name,
158
+            'balance' => 0,
159
+            'active' => true,
160
+            'default' => false,
161
+            'created_by' => $account->created_by,
162
+            'updated_by' => $account->updated_by,
163
+        ]);
164
+    }
165
+
166
+    private function getSubTypeId(int $companyId, AccountType $type): ?int
167
+    {
168
+        $subType = AccountSubtype::where('company_id', $companyId)
169
+            ->where('name', 'Cash and Cash Equivalents')
170
+            ->where('type', $type)
171
+            ->first();
172
+
173
+        if (!$subType) {
174
+            $subType = AccountSubtype::where('company_id', $companyId)
175
+                ->where('type', $type)
176
+                ->first();
177
+        }
178
+
179
+        return $subType?->id;
180
+    }
181
+
182
+    private function updateChartBalance(Account $chart, mixed $amount): void
183
+    {
184
+        //$chart->balance += $amount;
185
+        //$chart->save();
186
+    }
102 187
 }

+ 80
- 0
app/Observers/BankAccountObserver.php Bestand weergeven

@@ -0,0 +1,80 @@
1
+<?php
2
+
3
+namespace App\Observers;
4
+
5
+use App\Enums\Accounting\AccountCategory;
6
+use App\Enums\Accounting\AccountType;
7
+use App\Models\Accounting\Account;
8
+use App\Models\Accounting\AccountSubtype;
9
+use App\Models\Banking\BankAccount;
10
+use Illuminate\Support\Facades\Log;
11
+
12
+class BankAccountObserver
13
+{
14
+    /**
15
+     * Handle the BankAccount "created" event.
16
+     */
17
+    public function created(BankAccount $bankAccount): void
18
+    {
19
+        //
20
+    }
21
+
22
+    /**
23
+     * Handle the BankAccount "creating" event.
24
+     */
25
+    public function creating(BankAccount $bankAccount): void
26
+    {
27
+        //
28
+    }
29
+
30
+    /**
31
+     * Get the default bank account subtype.
32
+     */
33
+    protected function getDefaultBankAccountSubtype(int $companyId, AccountType $type)
34
+    {
35
+        $subType = AccountSubtype::where('company_id', $companyId)
36
+            ->where('name', 'Cash and Cash Equivalents')
37
+            ->where('type', $type)
38
+            ->first();
39
+
40
+        if (!$subType) {
41
+            $subType = AccountSubtype::where('company_id', $companyId)
42
+                ->where('type', $type)
43
+                ->first();
44
+        }
45
+
46
+        return $subType?->id;
47
+    }
48
+
49
+    /**
50
+     * Handle the BankAccount "updated" event.
51
+     */
52
+    public function updated(BankAccount $bankAccount): void
53
+    {
54
+        //
55
+    }
56
+
57
+    /**
58
+     * Handle the BankAccount "deleted" event.
59
+     */
60
+    public function deleted(BankAccount $bankAccount): void
61
+    {
62
+        //
63
+    }
64
+
65
+    /**
66
+     * Handle the BankAccount "restored" event.
67
+     */
68
+    public function restored(BankAccount $bankAccount): void
69
+    {
70
+        //
71
+    }
72
+
73
+    /**
74
+     * Handle the BankAccount "force deleted" event.
75
+     */
76
+    public function forceDeleted(BankAccount $bankAccount): void
77
+    {
78
+        //
79
+    }
80
+}

+ 1
- 1
app/Providers/AppServiceProvider.php Bestand weergeven

@@ -29,7 +29,7 @@ class AppServiceProvider extends ServiceProvider
29 29
         $this->configurePanelSwitch();
30 30
 
31 31
         FilamentAsset::register([
32
-            Js::make('top-navigation', __DIR__ . '/../../resources/js/top-navigation.js'),
32
+            Js::make('TopNavigation', __DIR__ . '/../../resources/js/TopNavigation.js'),
33 33
         ]);
34 34
     }
35 35
 

+ 1
- 1
app/Providers/AuthServiceProvider.php Bestand weergeven

@@ -39,7 +39,7 @@ class AuthServiceProvider extends ServiceProvider
39 39
             Setting\Category::class,
40 40
             Setting\Discount::class,
41 41
             Setting\Tax::class,
42
-            Banking\Account::class,
42
+            Banking\BankAccount::class,
43 43
         ];
44 44
 
45 45
         foreach ($models as $model) {

+ 22
- 1
app/Providers/EventServiceProvider.php Bestand weergeven

@@ -8,20 +8,30 @@ use App\Events\CompanyDefaultUpdated;
8 8
 use App\Events\CompanyGenerated;
9 9
 use App\Events\CurrencyRateChanged;
10 10
 use App\Events\DefaultCurrencyChanged;
11
+use App\Events\PlaidSuccess;
12
+use App\Events\StartTransactionImport;
13
+use App\Listeners\ConfigureChartOfAccounts;
11 14
 use App\Listeners\ConfigureCompanyDefault;
12 15
 use App\Listeners\ConfigureCompanyNavigation;
13 16
 use App\Listeners\CreateCompanyDefaults;
17
+use App\Listeners\CreateConnectedAccount;
18
+use App\Listeners\HandleTransactionImport;
19
+use App\Listeners\PopulateAccountFromPlaid;
14 20
 use App\Listeners\SyncAssociatedModels;
21
+use App\Listeners\SyncTransactionsFromPlaid;
15 22
 use App\Listeners\SyncWithCompanyDefaults;
16 23
 use App\Listeners\UpdateAccountBalances;
17 24
 use App\Listeners\UpdateCurrencyRates;
18
-use App\Models\Banking\Account;
25
+use App\Models\Accounting\Account;
26
+use App\Models\Banking\BankAccount;
19 27
 use App\Models\Setting\Currency;
20 28
 use App\Observers\AccountObserver;
29
+use App\Observers\BankAccountObserver;
21 30
 use App\Observers\CurrencyObserver;
22 31
 use Illuminate\Auth\Events\Registered;
23 32
 use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
24 33
 use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
34
+use Wallo\FilamentCompanies\Events\CompanyCreated;
25 35
 
26 36
 class EventServiceProvider extends ServiceProvider
27 37
 {
@@ -46,6 +56,7 @@ class EventServiceProvider extends ServiceProvider
46 56
         ],
47 57
         CompanyGenerated::class => [
48 58
             CreateCompanyDefaults::class,
59
+            ConfigureChartOfAccounts::class,
49 60
         ],
50 61
         DefaultCurrencyChanged::class => [
51 62
             UpdateCurrencyRates::class,
@@ -53,6 +64,15 @@ class EventServiceProvider extends ServiceProvider
53 64
         CurrencyRateChanged::class => [
54 65
             UpdateAccountBalances::class,
55 66
         ],
67
+        PlaidSuccess::class => [
68
+            CreateConnectedAccount::class,
69
+            // PopulateAccountFromPlaid::class,
70
+            // SyncTransactionsFromPlaid::class,
71
+        ],
72
+        StartTransactionImport::class => [
73
+            // SyncTransactionsFromPlaid::class,
74
+            HandleTransactionImport::class,
75
+        ],
56 76
     ];
57 77
 
58 78
     /**
@@ -62,6 +82,7 @@ class EventServiceProvider extends ServiceProvider
62 82
      */
63 83
     protected $observers = [
64 84
         Currency::class => [CurrencyObserver::class],
85
+        BankAccount::class => [BankAccountObserver::class],
65 86
         Account::class => [AccountObserver::class],
66 87
     ];
67 88
 

+ 4
- 0
app/Providers/Filament/AdminPanelProvider.php Bestand weergeven

@@ -2,6 +2,7 @@
2 2
 
3 3
 namespace App\Providers\Filament;
4 4
 
5
+use Exception;
5 6
 use Filament\Http\Middleware\Authenticate;
6 7
 use Filament\Http\Middleware\DisableBladeIconComponents;
7 8
 use Filament\Http\Middleware\DispatchServingFilamentEvent;
@@ -20,6 +21,9 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
20 21
 
21 22
 class AdminPanelProvider extends PanelProvider
22 23
 {
24
+    /**
25
+     * @throws Exception
26
+     */
23 27
     public function panel(Panel $panel): Panel
24 28
     {
25 29
         return $panel

+ 4
- 0
app/Providers/Filament/UserPanelProvider.php Bestand weergeven

@@ -3,6 +3,7 @@
3 3
 namespace App\Providers\Filament;
4 4
 
5 5
 use App\Http\Middleware\Authenticate;
6
+use Exception;
6 7
 use Filament\Http\Middleware\DisableBladeIconComponents;
7 8
 use Filament\Http\Middleware\DispatchServingFilamentEvent;
8 9
 use Filament\Navigation\MenuItem;
@@ -24,6 +25,9 @@ use Wallo\FilamentCompanies\Pages\User\Profile;
24 25
 
25 26
 class UserPanelProvider extends PanelProvider
26 27
 {
28
+    /**
29
+     * @throws Exception
30
+     */
27 31
     public function panel(Panel $panel): Panel
28 32
     {
29 33
         return $panel

+ 13
- 2
app/Providers/FilamentCompaniesServiceProvider.php Bestand weergeven

@@ -19,7 +19,11 @@ use App\Actions\FilamentCompanies\UpdateUserPassword;
19 19
 use App\Actions\FilamentCompanies\UpdateUserProfileInformation;
20 20
 use App\Filament\Company\Pages\CreateCompany;
21 21
 use App\Http\Middleware\ConfigureCurrentCompany;
22
+use App\Livewire\UpdatePassword;
23
+use App\Livewire\UpdateProfileInformation;
22 24
 use App\Models\Company;
25
+use Exception;
26
+use Filament\Actions\CreateAction;
23 27
 use Filament\Forms\Components\DatePicker;
24 28
 use Filament\Forms\Components\DateTimePicker;
25 29
 use Filament\Forms\Components\Select;
@@ -48,6 +52,9 @@ use Wallo\FilamentCompanies\Socialite;
48 52
 
49 53
 class FilamentCompaniesServiceProvider extends PanelProvider
50 54
 {
55
+    /**
56
+     * @throws Exception
57
+     */
51 58
     public function panel(Panel $panel): Panel
52 59
     {
53 60
         return $panel
@@ -61,8 +68,8 @@ class FilamentCompaniesServiceProvider extends PanelProvider
61 68
                 FilamentCompanies::make()
62 69
                     ->userPanel('user')
63 70
                     ->switchCurrentCompany()
64
-                    ->updateProfileInformation()
65
-                    ->updatePasswords()
71
+                    ->updateProfileInformation(component: UpdateProfileInformation::class)
72
+                    ->updatePasswords(component: UpdatePassword::class)
66 73
                     ->setPasswords()
67 74
                     ->connectedAccounts()
68 75
                     ->manageBrowserSessions()
@@ -173,6 +180,10 @@ class FilamentCompaniesServiceProvider extends PanelProvider
173 180
     {
174 181
         $this->configureSelect();
175 182
 
183
+        CreateAction::configureUsing(static function (CreateAction $action) {
184
+            $action->createAnother(false);
185
+        });
186
+
176 187
         DatePicker::configureUsing(static function (DatePicker $component) {
177 188
             $component->native(false);
178 189
         });

+ 21
- 0
app/Providers/MacroServiceProvider.php Bestand weergeven

@@ -4,6 +4,8 @@ namespace App\Providers;
4 4
 
5 5
 use Akaunting\Money\Currency;
6 6
 use Akaunting\Money\Money;
7
+use App\Models\Accounting\AccountSubtype;
8
+use App\Utilities\Accounting\AccountCode;
7 9
 use BackedEnum;
8 10
 use Closure;
9 11
 use Filament\Forms\Components\Field;
@@ -102,6 +104,25 @@ class MacroServiceProvider extends ServiceProvider
102 104
             return $this;
103 105
         });
104 106
 
107
+        Field::macro('validateAccountCode', function (string | Closure | null $subtype = null): static {
108
+            $this
109
+                ->rules([
110
+                    fn (Field $component): Closure => static function (string $attribute, $value, Closure $fail) use ($subtype, $component) {
111
+                    $subtype = $component->evaluate($subtype);
112
+                    $chartSubtype = AccountSubtype::find($subtype);
113
+                    $type = $chartSubtype->type;
114
+
115
+                    if (!AccountCode::isValidCode($value, $type)) {
116
+                        $message = AccountCode::getMessage($type);
117
+
118
+                        $fail($message);
119
+                    }
120
+                },
121
+            ]);
122
+
123
+            return $this;
124
+        });
125
+
105 126
         TextColumn::macro('rate', function (string | Closure | null $computation = null): static {
106 127
             $this->formatStateUsing(static function (TextColumn $column, $state) use ($computation): ?string {
107 128
                 $computation = $column->evaluate($computation);

+ 47
- 68
app/Services/CompanyDefaultService.php Bestand weergeven

@@ -6,7 +6,6 @@ use App\Enums\CategoryType;
6 6
 use App\Models\Company;
7 7
 use App\Models\Setting\Appearance;
8 8
 use App\Models\Setting\Category;
9
-use App\Models\Setting\CompanyDefault;
10 9
 use App\Models\Setting\Currency;
11 10
 use App\Models\Setting\Discount;
12 11
 use App\Models\Setting\DocumentDefault;
@@ -20,119 +19,99 @@ class CompanyDefaultService
20 19
     public function createCompanyDefaults(Company $company, User $user, string $currencyCode, string $countryCode, string $language): void
21 20
     {
22 21
         DB::transaction(function () use ($company, $user, $currencyCode, $countryCode, $language) {
23
-            $categories = $this->createCategories($company, $user);
24
-            $currency = $this->createCurrency($company, $user, $currencyCode);
25
-            $salesTax = $this->createSalesTax($company, $user);
26
-            $purchaseTax = $this->createPurchaseTax($company, $user);
27
-            $salesDiscount = $this->createSalesDiscount($company, $user);
28
-            $purchaseDiscount = $this->createPurchaseDiscount($company, $user);
22
+            $this->createCategories($company, $user);
23
+            $this->createCurrency($company, $user, $currencyCode);
24
+            $this->createSalesTax($company, $user);
25
+            $this->createPurchaseTax($company, $user);
26
+            $this->createSalesDiscount($company, $user);
27
+            $this->createPurchaseDiscount($company, $user);
29 28
             $this->createAppearance($company, $user);
30 29
             $this->createDocumentDefaults($company, $user);
31 30
             $this->createLocalization($company, $user, $countryCode, $language);
32
-
33
-            $companyDefaults = [
34
-                'company_id' => $company->id,
35
-                'income_category_id' => $categories['income_category_id'],
36
-                'expense_category_id' => $categories['expense_category_id'],
37
-                'currency_code' => $currency->code,
38
-                'sales_tax_id' => $salesTax->id,
39
-                'purchase_tax_id' => $purchaseTax->id,
40
-                'sales_discount_id' => $salesDiscount->id,
41
-                'purchase_discount_id' => $purchaseDiscount->id,
42
-                'created_by' => $user->id,
43
-                'updated_by' => $user->id,
44
-            ];
45
-
46
-            CompanyDefault::firstOrCreate(['company_id' => $company->id], $companyDefaults);
47 31
         }, 5);
48 32
     }
49 33
 
50
-    private function createCategories(Company $company, User $user): array
34
+    private function createCategories(Company $company, User $user): void
51 35
     {
52
-        $incomeCategories = ['Salary', 'Bonus', 'Interest', 'Dividends', 'Rentals'];
53
-        $expenseCategories = ['Rent', 'Utilities', 'Food', 'Transportation', 'Entertainment'];
54
-
55
-        $shuffledCategories = [
56
-            ...array_map(static fn ($name) => ['name' => $name, 'type' => CategoryType::Income->value], $incomeCategories),
57
-            ...array_map(static fn ($name) => ['name' => $name, 'type' => CategoryType::Expense->value], $expenseCategories),
58
-        ];
36
+        $incomeCategories = ['Dividends', 'Interest Earned', 'Wages', 'Sales', 'Other Income'];
37
+        $expenseCategories = ['Rent or Mortgage', 'Utilities', 'Groceries', 'Transportation', 'Other Expense'];
38
+        $otherCategories = ['Transfer', 'Other'];
59 39
 
60
-        shuffle($shuffledCategories);
40
+        $defaultIncomeCategory = 'Sales';
41
+        $defaultExpenseCategory = 'Rent or Mortgage';
61 42
 
62
-        $incomeEnabled = $expenseEnabled = false;
43
+        $this->createCategory($company, $user, $defaultIncomeCategory, CategoryType::Income, true);
44
+        $this->createCategory($company, $user, $defaultExpenseCategory, CategoryType::Expense, true);
63 45
 
64
-        $enabledIncomeCategoryId = null;
65
-        $enabledExpenseCategoryId = null;
66
-
67
-        foreach ($shuffledCategories as $category) {
68
-            $enabled = false;
69
-            if (! $incomeEnabled && $category['type'] === CategoryType::Income->value) {
70
-                $enabled = $incomeEnabled = true;
71
-            } elseif (! $expenseEnabled && $category['type'] === CategoryType::Expense->value) {
72
-                $enabled = $expenseEnabled = true;
46
+        foreach ($incomeCategories as $incomeCategory) {
47
+            if ($incomeCategory !== $defaultIncomeCategory) {
48
+                $this->createCategory($company, $user, $incomeCategory, CategoryType::Income);
73 49
             }
50
+        }
74 51
 
75
-            $categoryModel = Category::factory()->create([
76
-                'company_id' => $company->id,
77
-                'name' => $category['name'],
78
-                'type' => $category['type'],
79
-                'enabled' => $enabled,
80
-                'created_by' => $user->id,
81
-                'updated_by' => $user->id,
82
-            ]);
83
-
84
-            if ($enabled && $category['type'] === CategoryType::Income->value) {
85
-                $enabledIncomeCategoryId = $categoryModel->id;
86
-            } elseif ($enabled && $category['type'] === CategoryType::Expense->value) {
87
-                $enabledExpenseCategoryId = $categoryModel->id;
52
+        foreach ($expenseCategories as $expenseCategory) {
53
+            if ($expenseCategory !== $defaultExpenseCategory) {
54
+                $this->createCategory($company, $user, $expenseCategory, CategoryType::Expense);
88 55
             }
89 56
         }
90 57
 
91
-        return [
92
-            'income_category_id' => $enabledIncomeCategoryId,
93
-            'expense_category_id' => $enabledExpenseCategoryId,
94
-        ];
58
+        foreach ($otherCategories as $otherCategory) {
59
+            $this->createCategory($company, $user, $otherCategory, CategoryType::Other);
60
+        }
61
+
62
+    }
63
+
64
+    private function createCategory(Company $company, User $user, string $name, CategoryType $type, bool $enabled = false): void
65
+    {
66
+        Category::factory()->create([
67
+            'company_id' => $company->id,
68
+            'name' => $name,
69
+            'type' => $type,
70
+            'enabled' => $enabled,
71
+            'created_by' => $user->id,
72
+            'updated_by' => $user->id,
73
+        ]);
95 74
     }
96 75
 
97
-    private function createCurrency(Company $company, User $user, string $currencyCode)
76
+    private function createCurrency(Company $company, User $user, string $currencyCode): void
98 77
     {
99
-        return Currency::factory()->forCurrency($currencyCode)->create([
78
+        Currency::factory()->forCurrency($currencyCode)->create([
100 79
             'company_id' => $company->id,
101 80
             'created_by' => $user->id,
102 81
             'updated_by' => $user->id,
103 82
         ]);
104 83
     }
105 84
 
106
-    private function createSalesTax(Company $company, User $user)
85
+    private function createSalesTax(Company $company, User $user): void
107 86
     {
108
-        return Tax::factory()->salesTax()->create([
87
+        Tax::factory()->salesTax()->create([
109 88
             'company_id' => $company->id,
110 89
             'created_by' => $user->id,
111 90
             'updated_by' => $user->id,
112 91
         ]);
113 92
     }
114 93
 
115
-    private function createPurchaseTax(Company $company, User $user)
94
+    private function createPurchaseTax(Company $company, User $user): void
116 95
     {
117
-        return Tax::factory()->purchaseTax()->create([
96
+        Tax::factory()->purchaseTax()->create([
118 97
             'company_id' => $company->id,
119 98
             'created_by' => $user->id,
120 99
             'updated_by' => $user->id,
121 100
         ]);
122 101
     }
123 102
 
124
-    private function createSalesDiscount(Company $company, User $user)
103
+    private function createSalesDiscount(Company $company, User $user): void
125 104
     {
126
-        return Discount::factory()->salesDiscount()->create([
105
+        Discount::factory()->salesDiscount()->create([
127 106
             'company_id' => $company->id,
128 107
             'created_by' => $user->id,
129 108
             'updated_by' => $user->id,
130 109
         ]);
131 110
     }
132 111
 
133
-    private function createPurchaseDiscount(Company $company, User $user)
112
+    private function createPurchaseDiscount(Company $company, User $user): void
134 113
     {
135
-        return Discount::factory()->purchaseDiscount()->create([
114
+        Discount::factory()->purchaseDiscount()->create([
136 115
             'company_id' => $company->id,
137 116
             'created_by' => $user->id,
138 117
             'updated_by' => $user->id,

+ 293
- 0
app/Services/PlaidService.php Bestand weergeven

@@ -0,0 +1,293 @@
1
+<?php
2
+
3
+namespace App\Services;
4
+
5
+use App\Models\Company;
6
+use GuzzleHttp\Psr7\Message;
7
+use Illuminate\Contracts\Config\Repository as Config;
8
+use Illuminate\Http\Client\Factory as HttpClient;
9
+use Illuminate\Http\Client\RequestException;
10
+use Illuminate\Http\Client\Response;
11
+use Illuminate\Support\Facades\Log;
12
+use RuntimeException;
13
+
14
+class PlaidService
15
+{
16
+    public const string API_VERSION = '2020-09-14';
17
+
18
+    protected ?string $client_id;
19
+
20
+    protected ?string $client_secret;
21
+
22
+    protected ?string $environment;
23
+
24
+    protected ?string $base_url;
25
+
26
+    protected HttpClient $client;
27
+
28
+    protected Config $config;
29
+
30
+    protected array $plaidSupportedLanguages = [
31
+        'da', 'nl', 'en',
32
+        'et', 'fr', 'de',
33
+        'it', 'lv', 'lt',
34
+        'no', 'pl', 'pt',
35
+        'ro', 'es', 'sv',
36
+    ];
37
+
38
+    protected array $plaidSupportedCountries = [
39
+        'US', 'GB', 'ES',
40
+        'NL', 'FR', 'IE',
41
+        'CA', 'DE', 'IT',
42
+        'PL', 'DK', 'NO',
43
+        'SE', 'EE', 'LT',
44
+        'LV', 'PT', 'BE',
45
+    ];
46
+
47
+    public function __construct(HttpClient $client, Config $config)
48
+    {
49
+        $this->client = $client;
50
+        $this->config = $config;
51
+        $this->client_id = $this->config->get('plaid.client_id');
52
+        $this->client_secret = $this->config->get('plaid.client_secret');
53
+        $this->environment = $this->config->get('plaid.environment', 'sandbox');
54
+
55
+        $this->setBaseUrl($this->environment);
56
+    }
57
+
58
+    public function setClientCredentials(?string $client_id, ?string $client_secret): self
59
+    {
60
+        $this->client_id = $client_id ?? $this->client_id;
61
+        $this->client_secret = $client_secret ?? $this->client_secret;
62
+
63
+        return $this;
64
+    }
65
+
66
+    public function setEnvironment(?string $environment): self
67
+    {
68
+        $this->environment = $environment ?? $this->environment;
69
+
70
+        $this->setBaseUrl($this->environment);
71
+
72
+        return $this;
73
+    }
74
+
75
+    public function setBaseUrl(?string $environment): void
76
+    {
77
+        $this->base_url = match ($environment) {
78
+            'development' => 'https://development.plaid.com',
79
+            'production' => 'https://production.plaid.com',
80
+            default => 'https://sandbox.plaid.com', // Default to sandbox, including if environment is null
81
+        };
82
+    }
83
+
84
+    public function getBaseUrl(): string
85
+    {
86
+        return $this->base_url;
87
+    }
88
+
89
+    public function getEnvironment(): string
90
+    {
91
+        return $this->environment;
92
+    }
93
+
94
+    public function buildRequest(string $method, string $endpoint, array $data = []): Response
95
+    {
96
+        $request = $this->client->withHeaders([
97
+            'Plaid-Version' => self::API_VERSION,
98
+            'Content-Type' => 'application/json',
99
+        ])->baseUrl($this->base_url);
100
+
101
+        if ($method === 'post') {
102
+            $request = $request->withHeaders([
103
+                'PLAID-CLIENT-ID' => $this->client_id,
104
+                'PLAID-SECRET' => $this->client_secret,
105
+            ]);
106
+        }
107
+
108
+        return $request->{$method}($endpoint, $data);
109
+    }
110
+
111
+    public function sendRequest(string $endpoint, array $data = []): object
112
+    {
113
+        try {
114
+            $response = $this->buildRequest('post', $endpoint, $data)->throw()->object();
115
+
116
+            if ($response === null) {
117
+                throw new RuntimeException('Plaid API returned null response.');
118
+            }
119
+
120
+            return $response;
121
+        } catch (RequestException $e) {
122
+            $statusCode = $e->response->status();
123
+
124
+            $message = "Plaid API request returned status code {$statusCode}";
125
+
126
+            $summary = Message::bodySummary($e->response->toPsrResponse(), 1000);
127
+
128
+            if ($summary !== null) {
129
+                $message .= ":\n{$summary}\n";
130
+            }
131
+
132
+            Log::error($message);
133
+
134
+            throw new RuntimeException('An error occurred while communicating with the Plaid API.');
135
+        }
136
+    }
137
+
138
+    public function createPlaidUser(Company $company): array
139
+    {
140
+        return array_filter([
141
+            'client_user_id' => (string) $company->owner->id,
142
+            'legal_name' => $company->owner->name,
143
+            'phone_number' => $company->profile->phone_number,
144
+            'email_address' => $company->owner->email,
145
+        ], static fn ($value): bool => $value !== null);
146
+    }
147
+
148
+    public function getLanguage(string $language): string
149
+    {
150
+        if (in_array($language, $this->plaidSupportedLanguages, true)) {
151
+            return $language;
152
+        }
153
+
154
+        return 'en';
155
+    }
156
+
157
+    public function getCountry(string $country): string
158
+    {
159
+        if (in_array($country, $this->plaidSupportedCountries, true)) {
160
+            return $country;
161
+        }
162
+
163
+        return 'US';
164
+    }
165
+
166
+    public function createSandboxPublicToken(): object
167
+    {
168
+        return $this->createPublicToken('ins_109508', ['auth']);
169
+    }
170
+
171
+    public function createPublicToken(string $institution_id, array $initial_products, array $options = []): object
172
+    {
173
+        $data = [
174
+            'institution_id' => $institution_id,
175
+            'initial_products' => $initial_products,
176
+            'options' => (object) $options,
177
+        ];
178
+
179
+        return $this->sendRequest('sandbox/public_token/create', $data);
180
+    }
181
+
182
+    public function createToken(string $language, string $country, array $user, array $products = []): object
183
+    {
184
+        $plaidLanguage = $this->getLanguage($language);
185
+
186
+        $plaidCountry = $this->getCountry($country);
187
+
188
+        return $this->createLinkToken(
189
+            'ERPSAAS',
190
+            $plaidLanguage,
191
+            [$plaidCountry],
192
+            $user,
193
+            $products,
194
+        );
195
+    }
196
+
197
+    public function createLinkToken(string $client_name, string $language, array $country_codes, array $user, array $products = []): object
198
+    {
199
+        $data = [
200
+            'client_name' => $client_name,
201
+            'language' => $language,
202
+            'country_codes' => $country_codes,
203
+            'user' => (object) $user,
204
+        ];
205
+
206
+        if ($products) {
207
+            $data['products'] = $products;
208
+        }
209
+
210
+        return $this->sendRequest('link/token/create', $data);
211
+    }
212
+
213
+    public function exchangePublicToken(string $public_token): object
214
+    {
215
+        $data = compact('public_token');
216
+
217
+        return $this->sendRequest('item/public_token/exchange', $data);
218
+    }
219
+
220
+    public function getAccounts(string $accessToken, array $options = []): object
221
+    {
222
+        $data = [
223
+            'access_token' => $accessToken,
224
+            'options' => (object) $options,
225
+        ];
226
+
227
+        return $this->sendRequest('accounts/get', $data);
228
+    }
229
+
230
+    public function getAuth($access_token): object
231
+    {
232
+        return $this->authGet($access_token);
233
+    }
234
+
235
+    public function authGet(string $access_token, array $options = []): object
236
+    {
237
+        $data = [
238
+            'access_token' => $access_token,
239
+            'options' => (object) $options,
240
+        ];
241
+
242
+        return $this->sendRequest('auth/get', $data);
243
+    }
244
+
245
+    public function getInstitution(string $institution_id, string $country): object
246
+    {
247
+        $options = [
248
+            'include_optional_metadata' => true,
249
+        ];
250
+
251
+        $plaidCountry = $this->getCountry($country);
252
+
253
+        return $this->getInstitutionById($institution_id, [$plaidCountry], $options);
254
+    }
255
+
256
+    public function getInstitutions(int $count, int $offset, array $country_codes, array $options = []): object
257
+    {
258
+        $data = [
259
+            'count' => $count,
260
+            'offset' => $offset,
261
+            'country_codes' => $country_codes,
262
+            'options' => (object) $options,
263
+        ];
264
+
265
+        return $this->sendRequest('institutions/get', $data);
266
+    }
267
+
268
+    public function getInstitutionById(string $institution_id, array $country_codes, array $options = []): object
269
+    {
270
+        $data = [
271
+            'institution_id' => $institution_id,
272
+            'country_codes' => $country_codes,
273
+            'options' => (object) $options,
274
+        ];
275
+
276
+        return $this->sendRequest('institutions/get_by_id', $data);
277
+    }
278
+
279
+    public function syncTransactions(string $access_token, string|null $cursor = null, int $count = 100, array $options = []): object
280
+    {
281
+        $data = [
282
+            'access_token' => $access_token,
283
+            'count' => $count,
284
+            'options' => (object) $options,
285
+        ];
286
+
287
+        if ($cursor !== null) {
288
+            $data['cursor'] = $cursor;
289
+        }
290
+
291
+        return $this->sendRequest('transactions/sync', $data);
292
+    }
293
+}

+ 4
- 4
app/Traits/Blamable.php Bestand weergeven

@@ -8,14 +8,14 @@ trait Blamable
8 8
 {
9 9
     public static function bootBlamable(): void
10 10
     {
11
-        $auth = Auth::check() ? Auth::id() : null;
12
-
13
-        static::creating(static function ($model) use ($auth) {
11
+        static::creating(static function ($model) {
12
+            $auth = Auth::id();
14 13
             $model->created_by = $auth;
15 14
             $model->updated_by = $auth;
16 15
         });
17 16
 
18
-        static::updating(static function ($model) use ($auth) {
17
+        static::updating(static function ($model) {
18
+            $auth = Auth::id();
19 19
             $model->updated_by = $auth;
20 20
         });
21 21
     }

+ 2
- 2
app/Traits/HandlesResourceRecordCreation.php Bestand weergeven

@@ -12,7 +12,7 @@ trait HandlesResourceRecordCreation
12 12
     protected function handleRecordCreationWithUniqueField(array $data, Model $model, User $user, ?string $uniqueField = null, ?array $evaluatedTypes = null): Model
13 13
     {
14 14
         if (is_array($evaluatedTypes)) {
15
-            $evaluatedTypes = $this->ensureEnumValues($evaluatedTypes);
15
+            $evaluatedTypes = $this->ensureCreationEnumValues($evaluatedTypes);
16 16
         }
17 17
 
18 18
         if ($uniqueField && ! in_array($data[$uniqueField] ?? '', $evaluatedTypes ?? [], true)) {
@@ -43,7 +43,7 @@ trait HandlesResourceRecordCreation
43 43
         return $instance;
44 44
     }
45 45
 
46
-    private function ensureEnumValues(array $evaluatedTypes): array
46
+    private function ensureCreationEnumValues(array $evaluatedTypes): array
47 47
     {
48 48
         return array_map(static function ($type) {
49 49
             return $type instanceof BackedEnum ? $type->value : $type;

+ 2
- 2
app/Traits/HandlesResourceRecordUpdate.php Bestand weergeven

@@ -12,7 +12,7 @@ trait HandlesResourceRecordUpdate
12 12
     protected function handleRecordUpdateWithUniqueField(Model $record, array $data, User $user, ?string $uniqueField = null, ?array $evaluatedTypes = null): Model
13 13
     {
14 14
         if (is_array($evaluatedTypes)) {
15
-            $evaluatedTypes = $this->ensureEnumValues($evaluatedTypes);
15
+            $evaluatedTypes = $this->ensureUpdateEnumValues($evaluatedTypes);
16 16
         }
17 17
 
18 18
         if ($uniqueField && ! in_array($data[$uniqueField] ?? '', $evaluatedTypes ?? [], true)) {
@@ -51,7 +51,7 @@ trait HandlesResourceRecordUpdate
51 51
         return tap($record)->update($data);
52 52
     }
53 53
 
54
-    private function ensureEnumValues(array $evaluatedTypes): array
54
+    private function ensureUpdateEnumValues(array $evaluatedTypes): array
55 55
     {
56 56
         return array_map(static function ($type) {
57 57
             return $type instanceof BackedEnum ? $type->value : $type;

+ 83
- 0
app/Utilities/Accounting/AccountCode.php Bestand weergeven

@@ -0,0 +1,83 @@
1
+<?php
2
+
3
+namespace App\Utilities\Accounting;
4
+
5
+use App\Enums\Accounting\AccountType;
6
+use App\Models\Accounting\Account;
7
+use App\Models\Accounting\AccountSubtype;
8
+use RuntimeException;
9
+
10
+class AccountCode
11
+{
12
+    public static function isValidCode($code, AccountType $type): bool
13
+    {
14
+        $range = self::getRangeForType($type);
15
+
16
+        $mainAccountPart = explode('-', $code)[0];
17
+
18
+        $numericValue = (int) $mainAccountPart;
19
+
20
+        return $numericValue >= $range[0] && $numericValue <= $range[1];
21
+    }
22
+
23
+    public static function getMessage(AccountType $type): string
24
+    {
25
+        $range = self::getRangeForType($type);
26
+
27
+        return "The account code must range from {$range[0]} to {$range[1]} for a {$type->getLabel()}.";
28
+    }
29
+
30
+    public static function getRangeForType(AccountType $type): array
31
+    {
32
+        return match ($type) {
33
+            AccountType::CurrentAsset => [1000, 1499],
34
+            AccountType::NonCurrentAsset => [1500, 1899],
35
+            AccountType::ContraAsset => [1900, 1999],
36
+            AccountType::CurrentLiability => [2000, 2499],
37
+            AccountType::NonCurrentLiability => [2500, 2899],
38
+            AccountType::ContraLiability => [2900, 2999],
39
+            AccountType::Equity => [3000, 3899],
40
+            AccountType::ContraEquity => [3900, 3999],
41
+            AccountType::OperatingRevenue => [4000, 4499],
42
+            AccountType::NonOperatingRevenue => [4500, 4899],
43
+            AccountType::ContraRevenue => [4900, 4949],
44
+            AccountType::UncategorizedRevenue => [4950, 4999],
45
+            AccountType::OperatingExpense => [5000, 5499],
46
+            AccountType::NonOperatingExpense => [5500, 5899],
47
+            AccountType::ContraExpense => [5900, 5949],
48
+            AccountType::UncategorizedExpense => [5950, 5999],
49
+        };
50
+    }
51
+
52
+    public static function generate(int $companyId, string $subtypeId): string
53
+    {
54
+        $subtype = AccountSubtype::find($subtypeId);
55
+        $type = $subtype->type;
56
+
57
+        $range = self::getRangeForType($type);
58
+
59
+        $lastAccount = Account::where('subtype_id', $subtypeId)
60
+            ->where('company_id', $companyId)
61
+            ->orderBy('code', 'desc')
62
+            ->first(); // maybe handle subaccounts (parent-child) in the future (not using max() because of subaccounts)
63
+
64
+        if ($lastAccount) {
65
+            $lastCode = $lastAccount->code;
66
+            $lastAccountPart = explode('-', $lastCode)[0]; // possibly handle subaccounts (parent-child) in the future
67
+            $numericValue = (int) $lastAccountPart;
68
+            $numericValue++;
69
+        } else {
70
+            $numericValue = $range[0];
71
+        }
72
+
73
+        while (Account::where('company_id', $companyId)->where('code', '=', (string) $numericValue)->exists() || $numericValue > $range[1]) {
74
+            if ($numericValue > $range[1]) {
75
+                throw new RuntimeException('No more account codes available for this type.');
76
+            }
77
+
78
+            $numericValue++;
79
+        }
80
+
81
+        return (string) $numericValue;
82
+    }
83
+}

+ 4
- 2
app/Utilities/Localization/Timezone.php Bestand weergeven

@@ -5,11 +5,12 @@ namespace App\Utilities\Localization;
5 5
 use App\Enums\TimeFormat;
6 6
 use App\Models\Setting\Localization;
7 7
 use DateTimeZone;
8
+use IntlTimeZone;
8 9
 use Symfony\Component\Intl\Timezones;
9 10
 
10 11
 class Timezone
11 12
 {
12
-    public static function getTimezoneOptions(#[\SensitiveParameter] ?string $countryCode = null): array
13
+    public static function getTimezoneOptions(?string $countryCode = null): array
13 14
     {
14 15
         if (empty($countryCode)) {
15 16
             return [];
@@ -26,7 +27,8 @@ class Timezone
26 27
         $results = [];
27 28
 
28 29
         foreach ($countryTimezones as $timezoneIdentifier) {
29
-            $translatedName = $localizedTimezoneNames[$timezoneIdentifier] ?? $timezoneIdentifier;
30
+            $timezoneConical = IntlTimeZone::getCanonicalID($timezoneIdentifier);
31
+            $translatedName = $localizedTimezoneNames[$timezoneConical] ?? $timezoneConical;
30 32
             $cityName = self::extractCityName($translatedName);
31 33
             $localTime = self::getLocalTime($timezoneIdentifier);
32 34
             $timezoneAbbreviation = now($timezoneIdentifier)->format('T');

+ 121
- 0
app/Utilities/Plaid/AccountTypeMapper.php Bestand weergeven

@@ -0,0 +1,121 @@
1
+<?php
2
+
3
+namespace App\Utilities\Plaid;
4
+
5
+use App\Enums\BankAccountSubtype;
6
+use App\Enums\BankAccountType;
7
+
8
+class AccountTypeMapper
9
+{
10
+    public static function mapToEnums(string $plaidType, string $plaidSubtype): array {
11
+        $mappedType = self::mapType($plaidType);
12
+        $mappedSubtype = self::mapSubtype($plaidType, $plaidSubtype);
13
+
14
+        return [
15
+            'type' => $mappedType,
16
+            'subtype' => $mappedSubtype,
17
+        ];
18
+    }
19
+
20
+    private static function mapType(string $plaidType): BankAccountType {
21
+        return match ($plaidType) {
22
+            'depository' => BankAccountType::Depository,
23
+            'credit' => BankAccountType::Credit,
24
+            'loan' => BankAccountType::Loan,
25
+            'investment' => BankAccountType::Investment,
26
+            default => BankAccountType::Other,
27
+        };
28
+    }
29
+
30
+    private static function mapSubtype(string $plaidType, string $plaidSubtype): ?BankAccountSubtype
31
+    {
32
+        return match ($plaidType) {
33
+            'depository' => match ($plaidSubtype) {
34
+                'checking' => BankAccountSubtype::Checking,
35
+                'savings' => BankAccountSubtype::Savings,
36
+                'hsa' => BankAccountSubtype::HealthSavingsAccountCash,
37
+                'cd' => BankAccountSubtype::CertificateOfDeposit,
38
+                'money market' => BankAccountSubtype::MoneyMarket,
39
+                'paypal' => BankAccountSubtype::Paypal,
40
+                'prepaid' => BankAccountSubtype::Prepaid,
41
+                'cash management' => BankAccountSubtype::CashManagement,
42
+                'ebt' => BankAccountSubtype::ElectronicBenefitsTransfer,
43
+                default => BankAccountSubtype::Other,
44
+            },
45
+            'credit' => match ($plaidSubtype) {
46
+                'credit card' => BankAccountSubtype::CreditCard,
47
+                'paypal' => BankAccountSubtype::PaypalCredit,
48
+                default => BankAccountSubtype::Other,
49
+            },
50
+            'loan' => match ($plaidSubtype) {
51
+                'auto' => BankAccountSubtype::Auto,
52
+                'business' => BankAccountSubtype::Business,
53
+                'commercial' => BankAccountSubtype::Commercial,
54
+                'construction' => BankAccountSubtype::Construction,
55
+                'consumer' => BankAccountSubtype::Consumer,
56
+                'home equity' => BankAccountSubtype::HomeEquity,
57
+                'loan' => BankAccountSubtype::Loan,
58
+                'mortgage' => BankAccountSubtype::Mortgage,
59
+                'overdraft' => BankAccountSubtype::Overdraft,
60
+                'line of credit' => BankAccountSubtype::LineOfCredit,
61
+                'student' => BankAccountSubtype::Student,
62
+                default => BankAccountSubtype::Other,
63
+            },
64
+            'investment' => match ($plaidSubtype) {
65
+                '529' => BankAccountSubtype::CollegeSavings529,
66
+                '401a' => BankAccountSubtype::Retirement401a,
67
+                '401k' => BankAccountSubtype::Retirement401K,
68
+                '403B' => BankAccountSubtype::Retirement403b,
69
+                '457b' => BankAccountSubtype::DeferredCompensation457b,
70
+                'brokerage' => BankAccountSubtype::Brokerage,
71
+                'cash isa' => BankAccountSubtype::CashIndividualSavingsAccount,
72
+                'crypto exchange' => BankAccountSubtype::CryptoCurrencyExchange,
73
+                'education savings account' => BankAccountSubtype::EducationSavingsAccount,
74
+                'fixed annuity' => BankAccountSubtype::FixedAnnuity,
75
+                'gic' => BankAccountSubtype::GuaranteedInvestmentCertificate,
76
+                'health reimbursement arrangement' => BankAccountSubtype::HealthReimbursementArrangement,
77
+                'hsa' => BankAccountSubtype::HealthSavingsAccountNonCash,
78
+                'ira' => BankAccountSubtype::IndividualRetirementAccount,
79
+                'isa' => BankAccountSubtype::IndividualSavingsAccount,
80
+                'keogh' => BankAccountSubtype::KeoghPlan,
81
+                'lif' => BankAccountSubtype::LifeIncomeFund,
82
+                'life insurance' => BankAccountSubtype::LifeInsuranceAccount,
83
+                'lira' => BankAccountSubtype::LockedInRetirementAccount,
84
+                'lrif' => BankAccountSubtype::LockedInRetirementIncomeFund,
85
+                'lrsp' => BankAccountSubtype::LockedInRetirementSavingsPlan,
86
+                'mutual fund' => BankAccountSubtype::MutualFundAccount,
87
+                'non-custodial wallet' => BankAccountSubtype::CryptoCurrencyWallet,
88
+                'non-taxable brokerage account' => BankAccountSubtype::NonTaxableBrokerageAccount,
89
+                'other annuity' => BankAccountSubtype::AnnuityAccountOther,
90
+                'other insurance' => BankAccountSubtype::InsuranceAccountOther,
91
+                'pension' => BankAccountSubtype::PensionAccount,
92
+                'prif' => BankAccountSubtype::PrescribedRetirementIncomeFund,
93
+                'profit sharing plan' => BankAccountSubtype::ProfitSharingPlanAccount,
94
+                'qshr' => BankAccountSubtype::QualifyingShareAccount,
95
+                'rdsp' => BankAccountSubtype::RegisteredDisabilitySavingsPlan,
96
+                'resp' => BankAccountSubtype::RegisteredEducationSavingsPlan,
97
+                'retirement' => BankAccountSubtype::RetirementAccountOther,
98
+                'rlif' => BankAccountSubtype::RestrictedLifeIncomeFund,
99
+                'roth' => BankAccountSubtype::RothIRA,
100
+                'roth 401k' => BankAccountSubtype::Roth401k,
101
+                'rrif' => BankAccountSubtype::RegisteredRetirementIncomeFund,
102
+                'rrsp' => BankAccountSubtype::RegisteredRetirementSavingsPlan,
103
+                'sarsep' => BankAccountSubtype::SalaryReductionSEPPlan,
104
+                'sep ira' => BankAccountSubtype::SimplifiedEmployeePensionIRA,
105
+                'simple ira' => BankAccountSubtype::SavingsIncentiveMatchPlanForEmployeesIRA,
106
+                'sipp' => BankAccountSubtype::SelfInvestedPersonalPension,
107
+                'stock plan' => BankAccountSubtype::StockPlanAccount,
108
+                'tfsa' => BankAccountSubtype::TaxFreeSavingsAccount,
109
+                'trust' => BankAccountSubtype::TrustAccount,
110
+                'ugma' => BankAccountSubtype::UniformGiftToMinorsAct,
111
+                'utma' => BankAccountSubtype::UniformTransfersToMinorsAct,
112
+                'variable annuity' => BankAccountSubtype::VariableAnnuityAccount,
113
+                default => BankAccountSubtype::Other,
114
+            },
115
+
116
+            default => BankAccountSubtype::Other,
117
+
118
+        };
119
+
120
+    }
121
+}

+ 0
- 1
composer.json Bestand weergeven

@@ -11,7 +11,6 @@
11 11
         "php": "^8.2",
12 12
         "ext-bcmath": "*",
13 13
         "ext-intl": "*",
14
-        "ext-simplexml": "*",
15 14
         "akaunting/laravel-money": "^5.1",
16 15
         "andrewdwallo/filament-companies": "^3.0",
17 16
         "andrewdwallo/filament-selectify": "^2.0",

+ 758
- 632
composer.lock
Diff onderdrukt omdat het te groot bestand
Bestand weergeven


+ 424
- 0
config/chart-of-accounts.php Bestand weergeven

@@ -0,0 +1,424 @@
1
+<?php
2
+
3
+return [
4
+    'default' => [
5
+        'current_asset' => [
6
+            'Cash and Cash Equivalents' => [
7
+                'description' => 'The most liquid assets a company holds. This includes physical currency, bank balances, and short-term investments a company can quickly convert to cash.',
8
+                'multi_currency' => true,
9
+                'base_code' => '1000',
10
+                'accounts' => [
11
+                    'Cash on Hand' => [
12
+                        'description' => 'The amount of money held by the company in the form of cash.',
13
+                    ],
14
+                ],
15
+            ],
16
+            'Receivables' => [
17
+                'description' => 'Amounts owed to the company for goods sold or services rendered, including accounts receivable, notes receivable, and other receivables.',
18
+                'multi_currency' => false,
19
+                'base_code' => '1100',
20
+                'accounts' => [
21
+                    'Accounts Receivable' => [
22
+                        'description' => 'The amount of money owed to the company by customers who have not yet paid for goods or services received.',
23
+                    ],
24
+                ],
25
+            ],
26
+            'Inventory' => [
27
+                'description' => 'The raw materials, work-in-progress goods and completely finished goods that are considered to be the portion of a business\'s assets that are ready or will be ready for sale.',
28
+                'multi_currency' => true,
29
+                'base_code' => '1200',
30
+            ],
31
+            'Prepaid and Deferred Charges' => [
32
+                'description' => 'Payments made in advance for future goods or services, such as insurance premiums, rent, and prepaid taxes.',
33
+                'multi_currency' => false,
34
+                'base_code' => '1300',
35
+            ],
36
+            'Other Current Assets' => [
37
+                'description' => 'Other assets that are expected to be converted to cash, sold, or consumed within one year or the business\'s operating cycle.',
38
+                'multi_currency' => true,
39
+                'base_code' => '1400',
40
+            ],
41
+        ],
42
+        'non_current_asset' => [
43
+            'Long-Term Investments' => [
44
+                'description' => 'Investments in securities like bonds and stocks, investments in other companies, or real estate held for more than one year, aiming for long-term benefits.',
45
+                'multi_currency' => true,
46
+                'base_code' => '1500',
47
+            ],
48
+            'Fixed Assets' => [
49
+                'description' => 'Physical, tangible assets used in the business\'s operations with a useful life exceeding one year, such as buildings, machinery, and vehicles. These assets are subject to depreciation.',
50
+                'multi_currency' => false,
51
+                'base_code' => '1600',
52
+            ],
53
+            'Intangible Assets' => [
54
+                'description' => 'Assets lacking physical substance but offering value to the business, like patents, copyrights, trademarks, software, and goodwill.',
55
+                'multi_currency' => false,
56
+                'base_code' => '1700',
57
+            ],
58
+            'Other Non-Current Assets' => [
59
+                'description' => 'Includes long-term assets not classified in the above categories, such as long-term prepaid expenses, deferred tax assets, and loans made to other entities that are not expected to be settled within the next year.',
60
+                'multi_currency' => true,
61
+                'base_code' => '1800',
62
+            ],
63
+        ],
64
+        'contra_asset' => [
65
+            'Depreciation and Amortization' => [
66
+                'description' => 'Accounts that accumulate depreciation of tangible assets and amortization of intangible assets, reflecting the reduction in value over time.',
67
+                'multi_currency' => false,
68
+                'base_code' => '1900',
69
+            ],
70
+            'Allowances for Receivables' => [
71
+                'description' => 'Accounts representing estimated uncollectible receivables, used to adjust the value of gross receivables to a realistic collectible amount.',
72
+                'multi_currency' => false,
73
+                'base_code' => '1940',
74
+            ],
75
+            'Valuation Adjustments' => [
76
+                'description' => 'Accounts used to record adjustments in asset values due to impairments, market changes, or other factors affecting their recoverable amount.',
77
+                'multi_currency' => false,
78
+                'base_code' => '1950',
79
+            ],
80
+        ],
81
+        'current_liability' => [
82
+            'Supplier Obligations' => [
83
+                'description' => 'Liabilities arising from purchases of goods or services from suppliers, not yet paid for. This can include individual accounts payable and trade credits.',
84
+                'multi_currency' => true,
85
+                'base_code' => '2000',
86
+                'accounts' => [
87
+                    'Accounts Payable' => [
88
+                        'description' => 'The amount of money owed by the company to suppliers for goods or services received.',
89
+                    ],
90
+                ],
91
+            ],
92
+            'Accrued Expenses and Liabilities' => [
93
+                'description' => 'Expenses that have been incurred but not yet paid, including wages, utilities, interest, and taxes. This category can house various accrued expense accounts.',
94
+                'multi_currency' => false,
95
+                'base_code' => '2100',
96
+                'accounts' => [
97
+                    'Sales Tax Payable' => [
98
+                        'description' => 'The amount of money owed to the government for sales tax collected from customers.',
99
+                    ],
100
+                ],
101
+            ],
102
+            'Short-Term Borrowings' => [
103
+                'description' => 'Debt obligations due within the next year, such as bank loans, lines of credit, and short-term notes. This category can cover multiple short-term debt accounts.',
104
+                'multi_currency' => true,
105
+                'base_code' => '2200',
106
+            ],
107
+            'Customer Deposits and Advances' => [
108
+                'description' => 'Funds received in advance for goods or services to be provided in the future, including customer deposits and prepayments.',
109
+                'multi_currency' => true,
110
+                'base_code' => '2300',
111
+            ],
112
+            'Other Current Liabilities' => [
113
+                'description' => 'A grouping for miscellaneous short-term liabilities not covered in other categories, like the current portion of long-term debts, short-term provisions, and other similar obligations.',
114
+                'multi_currency' => true,
115
+                'base_code' => '2400',
116
+            ],
117
+        ],
118
+        'non_current_liability' => [
119
+            'Long-Term Borrowings' => [
120
+                'description' => 'Obligations such as bonds, mortgages, and loans with a maturity of more than one year, covering various types of long-term debt instruments.',
121
+                'multi_currency' => true,
122
+                'base_code' => '2500',
123
+            ],
124
+            'Deferred Tax Liabilities' => [
125
+                'description' => 'Taxes incurred in the current period but payable in a future period, typically due to differences in accounting methods between tax reporting and financial reporting.',
126
+                'multi_currency' => false,
127
+                'base_code' => '2600',
128
+            ],
129
+            'Other Long-Term Liabilities' => [
130
+                'description' => 'Liabilities not due within the next year and not classified as long-term debt or deferred taxes, including pension liabilities, lease obligations, and long-term provisions.',
131
+                'multi_currency' => true,
132
+                'base_code' => '2700',
133
+            ],
134
+        ],
135
+        'contra_liability' => [
136
+            'Accumulated Amortization of Debt Discount' => [
137
+                'description' => 'Accumulated amount representing the reduction of bond or loan liabilities, reflecting the difference between the face value and the discounted issuance price over time.',
138
+                'multi_currency' => false,
139
+                'base_code' => '2900',
140
+            ],
141
+            'Valuation Adjustments for Liabilities' => [
142
+                'description' => 'Adjustments made to the recorded value of liabilities, such as changes in fair value of derivative liabilities or adjustments for hedging activities.',
143
+                'multi_currency' => false,
144
+                'base_code' => '2950',
145
+            ],
146
+        ],
147
+        'equity' => [
148
+            'Contributed Capital' => [
149
+                'description' => 'Funds provided by owners or shareholders for starting the business and subsequent capital injections. Reflects the financial commitment of the owner(s) or shareholders to the business.',
150
+                'multi_currency' => true,
151
+                'base_code' => '3000',
152
+                'accounts' => [
153
+                    'Owner\'s Equity' => [
154
+                        'description' => 'The owner\'s financial interest in the business, representing the residual interest in the assets of the business after deducting liabilities.',
155
+                    ],
156
+                ],
157
+            ],
158
+            'Retained Earnings' => [
159
+                'description' => 'Cumulative profits retained in the business and not distributed as dividends. Indicates the company\'s financial health and profit-generating ability.',
160
+                'multi_currency' => false,
161
+                'base_code' => '3100',
162
+            ],
163
+            'Drawings' => [
164
+                'description' => 'The amount of money taken out of the business by the owner(s) for personal use.',
165
+                'multi_currency' => false,
166
+                'base_code' => '3200',
167
+            ],
168
+            'Equity Reserves and Adjustments' => [
169
+                'description' => 'Includes adjustments like revaluation reserves, foreign exchange adjustments, or other components of comprehensive income that affect the equity but are not classified under capital, retained earnings, or drawings.',
170
+                'multi_currency' => true,
171
+                'base_code' => '3300',
172
+            ],
173
+        ],
174
+        'contra_equity' => [
175
+            'Contra Equity' => [
176
+                'description' => 'Equity that is deducted from gross equity to arrive at net equity. This includes treasury stock, which is stock that has been repurchased by the company.',
177
+                'multi_currency' => false,
178
+                'base_code' => '3900',
179
+            ],
180
+        ],
181
+        'operating_revenue' => [
182
+            'Product Sales' => [
183
+                'description' => 'Income from selling physical or digital products. Includes revenue from all product lines or categories.',
184
+                'multi_currency' => false,
185
+                'base_code' => '4000',
186
+                'accounts' => [
187
+                    'Product Sales' => [
188
+                        'description' => 'The amount of money earned from selling physical or digital products.',
189
+                    ],
190
+                ],
191
+            ],
192
+            'Service Revenue' => [
193
+                'description' => 'Income earned from providing services, encompassing activities like consulting, maintenance, and repair services.',
194
+                'multi_currency' => false,
195
+                'base_code' => '4100',
196
+            ],
197
+            'Other Operating Revenue' => [
198
+                'description' => 'Income from other business operations not classified as product sales or services, such as rental income, royalties, or income from licensing agreements.',
199
+                'multi_currency' => false,
200
+                'base_code' => '4200',
201
+            ],
202
+        ],
203
+        'non_operating_revenue' => [
204
+            'Investment Income' => [
205
+                'description' => 'Earnings from investments, including dividends, interest from securities, and profits from real estate investments.',
206
+                'multi_currency' => false,
207
+                'base_code' => '4500',
208
+                'accounts' => [
209
+                    'Dividends' => [
210
+                        'description' => 'The amount of money received from investments in shares of other companies.',
211
+                    ],
212
+                    'Interest Earned' => [
213
+                        'description' => 'The amount of money earned from interest-bearing investments like bonds, certificates of deposit, or savings accounts.',
214
+                    ],
215
+                ],
216
+            ],
217
+            'Gains from Asset Disposition' => [
218
+                'description' => 'Profits from selling assets like property, equipment, or investments, excluding regular sales of inventory.',
219
+                'multi_currency' => false,
220
+                'base_code' => '4600',
221
+            ],
222
+            'Other Non-Operating Revenue' => [
223
+                'description' => 'Income from sources not related to the main business activities, such as legal settlements, insurance recoveries, or gains from foreign exchange transactions.',
224
+                'multi_currency' => false,
225
+                'base_code' => '4700',
226
+                'accounts' => [
227
+                    'Gain on Foreign Exchange' => [
228
+                        'description' => 'The amount of money earned from foreign exchange transactions due to favorable exchange rate changes.',
229
+                    ],
230
+                ],
231
+            ],
232
+        ],
233
+        'contra_revenue' => [
234
+            'Contra Revenue' => [
235
+                'description' => 'Revenue that is deducted from gross revenue to arrive at net revenue. This includes sales discounts, returns, and allowances.',
236
+                'multi_currency' => false,
237
+                'base_code' => '4900',
238
+                'accounts' => [
239
+                    'Sales Returns and Allowances' => [
240
+                        'description' => 'The amount of money returned to customers or deducted from sales due to returned goods or allowances granted.',
241
+                    ],
242
+                    'Sales Discounts' => [
243
+                        'description' => 'The amount of money deducted from sales due to discounts offered to customers for early payment or other reasons.',
244
+                    ],
245
+                ],
246
+            ],
247
+        ],
248
+        'uncategorized_revenue' => [
249
+            'Uncategorized Revenue' => [
250
+                'description' => 'Revenue that has not been categorized into other revenue categories.',
251
+                'multi_currency' => false,
252
+                'base_code' => '4950',
253
+                'accounts' => [
254
+                    'Uncategorized Income' => [
255
+                        'description' => 'Revenue from other business operations that don\'t fall under regular sales or services. This account is used as the default for all new transactions.',
256
+                    ],
257
+                ],
258
+            ],
259
+        ],
260
+        'operating_expense' => [
261
+            'Cost of Goods Sold' => [
262
+                'description' => 'Direct costs attributable to the production of goods sold by a company. This includes material costs and direct labor.',
263
+                'multi_currency' => false,
264
+                'base_code' => '5000',
265
+            ],
266
+            'Payroll and Employee Benefits' => [
267
+                'description' => 'Expenses related to employee compensation, including salaries, wages, bonuses, commissions, and payroll taxes.',
268
+                'multi_currency' => false,
269
+                'base_code' => '5050',
270
+                'accounts' => [
271
+                    'Salaries and Wages' => [
272
+                        'description' => 'The amount of money paid to employees for their work, including regular salaries and hourly wages.',
273
+                    ],
274
+                    'Payroll Employer Taxes and Contributions' => [
275
+                        'description' => 'The amount of money paid by the employer for payroll taxes and contributions, such as social security, unemployment, and workers\' compensation.',
276
+                    ],
277
+                    'Employee Benefits' => [
278
+                        'description' => 'The amount of money spent on employee benefits, such as health insurance, retirement plans, and other benefits.',
279
+                    ],
280
+                    'Payroll Processing Fees' => [
281
+                        'description' => 'The amount of money paid to third-party payroll processors for payroll services.',
282
+                    ],
283
+                ],
284
+            ],
285
+            'Facility Expenses' => [
286
+                'description' => 'Costs incurred for business premises, including rent or lease payments, property taxes, utilities, and building maintenance.',
287
+                'multi_currency' => false,
288
+                'base_code' => '5100',
289
+                'accounts' => [
290
+                    'Rent or Lease Payments' => [
291
+                        'description' => 'The amount of money paid for renting or leasing business premises.',
292
+                    ],
293
+                    'Property Taxes' => [
294
+                        'description' => 'The amount of money paid for taxes on business property.',
295
+                    ],
296
+                    'Building Maintenance' => [
297
+                        'description' => 'The amount of money spent on maintaining business premises, including repairs and cleaning.',
298
+                    ],
299
+                    'Utilities' => [
300
+                        'description' => 'The amount of money paid for business utilities, such as electricity, water, and gas.',
301
+                    ],
302
+                    'Property Insurance' => [
303
+                        'description' => 'The amount of money paid for insurance on business property.',
304
+                    ],
305
+                ],
306
+            ],
307
+            'General and Administrative' => [
308
+                'description' => 'Expenses related to general business operations, such as office supplies, insurance, and professional fees.',
309
+                'multi_currency' => false,
310
+                'base_code' => '5150',
311
+                'accounts' => [
312
+                    'Food and Drink' => [
313
+                        'description' => 'The amount of money spent on food and drink for business purposes, such as office snacks, meals, and catering.',
314
+                    ],
315
+                    'Transportation' => [
316
+                        'description' => 'The amount of money spent on business transportation, such as fuel, vehicle maintenance, and public transportation.',
317
+                    ],
318
+                    'Travel' => [
319
+                        'description' => 'The amount of money spent on business travel, such as airfare, hotels, and rental cars.',
320
+                    ],
321
+                    'Entertainment' => [
322
+                        'description' => 'The amount of money spent on business entertainment, such as client dinners, events, and tickets.',
323
+                    ],
324
+                    'Office Supplies' => [
325
+                        'description' => 'The amount of money spent on office supplies, such as paper, ink, and stationery.',
326
+                    ],
327
+                    'Office Equipment and Furniture' => [
328
+                        'description' => 'The amount of money spent on office equipment and furniture, such as computers, printers, and desks.',
329
+                    ],
330
+                    'Legal and Professional Fees' => [
331
+                        'description' => 'The amount of money paid for legal and professional services, such as legal advice, accounting, and consulting.',
332
+                    ],
333
+                    'Software and Subscriptions' => [
334
+                        'description' => 'The amount of money spent on software and subscriptions, such as SaaS products, cloud services, and digital tools.',
335
+                    ],
336
+                ],
337
+            ],
338
+            'Marketing and Advertising' => [
339
+                'description' => 'Expenses related to marketing and advertising activities, such as advertising campaigns, promotional events, and marketing materials.',
340
+                'multi_currency' => false,
341
+                'base_code' => '5200',
342
+                'accounts' => [
343
+                    'Advertising' => [
344
+                        'description' => 'The amount of money spent on advertising campaigns, including print, digital, and outdoor advertising.',
345
+                    ],
346
+                    'Marketing' => [
347
+                        'description' => 'The amount of money spent on marketing activities, such as content creation, social media, and email marketing.',
348
+                    ],
349
+                ],
350
+            ],
351
+            'Research and Development' => [
352
+                'description' => 'Expenses incurred in the process of researching and developing new products or services.',
353
+                'multi_currency' => false,
354
+                'base_code' => '5250',
355
+            ],
356
+            'Other Operating Expenses' => [
357
+                'description' => 'Miscellaneous expenses not categorized elsewhere, such as research and development costs, legal fees, and other irregular expenses.',
358
+                'multi_currency' => false,
359
+                'base_code' => '5300',
360
+            ],
361
+        ],
362
+        'non_operating_expense' => [
363
+            'Interest and Financing Costs' => [
364
+                'description' => 'Expenses related to borrowing and financing, such as interest payments on loans, bonds, and credit lines.',
365
+                'multi_currency' => false,
366
+                'base_code' => '5500',
367
+            ],
368
+            'Tax Expenses' => [
369
+                'description' => 'Various taxes incurred by the business, including income tax, sales tax, property tax, and payroll tax.',
370
+                'multi_currency' => false,
371
+                'base_code' => '5600',
372
+            ],
373
+            'Other Non-Operating Expense' => [
374
+                'description' => 'Expenses not related to primary business activities, like losses from asset disposals, legal settlements, restructuring costs, or foreign exchange losses.',
375
+                'multi_currency' => false,
376
+                'base_code' => '5700',
377
+                'accounts' => [
378
+                    'Loss on Foreign Exchange' => [
379
+                        'description' => 'The amount of money lost from foreign exchange transactions due to unfavorable exchange rate changes.',
380
+                    ],
381
+                ],
382
+            ],
383
+        ],
384
+        'contra_expense' => [
385
+             'Contra Expenses' => [
386
+                'description' => 'Expenses that are deducted from gross expenses to arrive at net expenses. This includes purchase discounts, returns, and allowances.',
387
+                'multi_currency' => false,
388
+                'base_code' => '5900',
389
+                'accounts' => [
390
+                    'Purchase Returns and Allowances' => [
391
+                        'description' => 'The amount of money returned to suppliers or deducted from purchases due to returned goods or allowances granted.',
392
+                    ],
393
+                    'Purchase Discounts' => [
394
+                        'description' => 'The amount of money deducted from purchases due to discounts offered by suppliers for early payment or other reasons.',
395
+                    ],
396
+                ],
397
+             ],
398
+        ],
399
+        'uncategorized_expense' => [
400
+            'Uncategorized Expense' => [
401
+                'description' => 'Expenses that have not been categorized into other expense categories.',
402
+                'multi_currency' => false,
403
+                'base_code' => '5950',
404
+                'accounts' => [
405
+                    'Uncategorized Expense' => [
406
+                        'description' => 'Expenses not classified into regular expense categories. This account is used as the default for all new transactions.',
407
+                    ],
408
+                ],
409
+            ],
410
+        ],
411
+    ],
412
+    'category_account_map' => [
413
+        'Dividends' => 'Dividends',
414
+        'Interest Earned' => 'Interest Earned',
415
+        'Wages' => 'Salaries and Wages',
416
+        'Sales' => 'Product Sales',
417
+        'Other Income' => 'Uncategorized Income',
418
+        'Rent or Mortgage' => 'Rent or Lease Payments',
419
+        'Utilities' => 'Utilities',
420
+        'Groceries' => 'Food and Drink',
421
+        'Transportation' => 'Transportation',
422
+        'Other Expenses' => 'Uncategorized Expense',
423
+    ],
424
+];

+ 7
- 0
config/plaid.php Bestand weergeven

@@ -0,0 +1,7 @@
1
+<?php
2
+
3
+return [
4
+    'client_id' => env('PLAID_CLIENT_ID'),
5
+    'client_secret' => env('PLAID_CLIENT_SECRET'),
6
+    'environment' => env('PLAID_ENVIRONMENT', 'sandbox'),
7
+];

+ 5
- 0
config/services.php Bestand weergeven

@@ -42,4 +42,9 @@ return [
42 42
         'base_url' => 'https://v6.exchangerate-api.com/v6',
43 43
     ],
44 44
 
45
+    'plaid' => [
46
+        'client_id' => env('PLAID_CLIENT_ID'),
47
+        'client_secret' => env('PLAID_CLIENT_SECRET'),
48
+        'environment' => env('PLAID_ENVIRONMENT', 'sandbox'),
49
+    ]
45 50
 ];

+ 30
- 0
database/factories/Accounting/AccountFactory.php Bestand weergeven

@@ -0,0 +1,30 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use App\Models\Accounting\Account;
6
+use Illuminate\Database\Eloquent\Factories\Factory;
7
+
8
+/**
9
+ * @extends Factory<Account>
10
+ */
11
+class AccountFactory extends Factory
12
+{
13
+
14
+    /**
15
+     * The name of the factory's corresponding model.
16
+     */
17
+    protected $model = Account::class;
18
+
19
+    /**
20
+     * Define the model's default state.
21
+     *
22
+     * @return array<string, mixed>
23
+     */
24
+    public function definition(): array
25
+    {
26
+        return [
27
+            //
28
+        ];
29
+    }
30
+}

+ 26
- 0
database/factories/Accounting/AccountSubtypeFactory.php Bestand weergeven

@@ -0,0 +1,26 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use App\Models\Accounting\AccountSubtype;
6
+use Illuminate\Database\Eloquent\Factories\Factory;
7
+
8
+/**
9
+ * @extends Factory<AccountSubtype>
10
+ */
11
+class AccountSubtypeFactory extends Factory
12
+{
13
+    protected $model = AccountSubtype::class;
14
+
15
+    /**
16
+     * Define the model's default state.
17
+     *
18
+     * @return array<string, mixed>
19
+     */
20
+    public function definition(): array
21
+    {
22
+        return [
23
+            //
24
+        ];
25
+    }
26
+}

+ 24
- 0
database/factories/Accounting/JournalEntryFactory.php Bestand weergeven

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use App\Models\Accounting\JournalEntry;
6
+use Illuminate\Database\Eloquent\Factories\Factory;
7
+
8
+/**
9
+ * @extends Factory<JournalEntry>
10
+ */
11
+class JournalEntryFactory extends Factory
12
+{
13
+    /**
14
+     * Define the model's default state.
15
+     *
16
+     * @return array<string, mixed>
17
+     */
18
+    public function definition(): array
19
+    {
20
+        return [
21
+            //
22
+        ];
23
+    }
24
+}

+ 24
- 0
database/factories/Accounting/TransactionFactory.php Bestand weergeven

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use App\Models\Accounting\Transaction;
6
+use Illuminate\Database\Eloquent\Factories\Factory;
7
+
8
+/**
9
+ * @extends Factory<Transaction>
10
+ */
11
+class TransactionFactory extends Factory
12
+{
13
+    /**
14
+     * Define the model's default state.
15
+     *
16
+     * @return array<string, mixed>
17
+     */
18
+    public function definition(): array
19
+    {
20
+        return [
21
+            //
22
+        ];
23
+    }
24
+}

database/factories/Banking/AccountFactory.php → database/factories/Banking/BankAccountFactory.php Bestand weergeven

@@ -2,13 +2,13 @@
2 2
 
3 3
 namespace Database\Factories\Banking;
4 4
 
5
-use App\Models\Banking\Account;
5
+use App\Models\Banking\BankAccount;
6 6
 use Illuminate\Database\Eloquent\Factories\Factory;
7 7
 
8 8
 /**
9
- * @extends Factory<Account>
9
+ * @extends Factory<BankAccount>
10 10
  */
11
-class AccountFactory extends Factory
11
+class BankAccountFactory extends Factory
12 12
 {
13 13
     /**
14 14
      * Define the model's default state.

+ 24
- 0
database/factories/Banking/InstitutionFactory.php Bestand weergeven

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+namespace Database\Factories\Banking;
4
+
5
+use App\Models\Banking\Institution;
6
+use Illuminate\Database\Eloquent\Factories\Factory;
7
+
8
+/**
9
+ * @extends Factory<Institution>
10
+ */
11
+class InstitutionFactory extends Factory
12
+{
13
+    /**
14
+     * Define the model's default state.
15
+     *
16
+     * @return array<string, mixed>
17
+     */
18
+    public function definition(): array
19
+    {
20
+        return [
21
+            //
22
+        ];
23
+    }
24
+}

+ 108
- 0
database/migrations/2023_09_03_100000_create_accounting_tables.php Bestand weergeven

@@ -0,0 +1,108 @@
1
+<?php
2
+
3
+use App\Enums\BankAccountType;
4
+use Illuminate\Database\Migrations\Migration;
5
+use Illuminate\Database\Schema\Blueprint;
6
+use Illuminate\Support\Facades\Schema;
7
+
8
+return new class extends Migration
9
+{
10
+    /**
11
+     * Run the migrations.
12
+     */
13
+    public function up(): void
14
+    {
15
+        Schema::create('institutions', function (Blueprint $table) {
16
+            $table->id();
17
+            $table->string('external_institution_id')->nullable(); // Plaid
18
+            $table->string('name')->index();
19
+            $table->string('logo')->nullable();
20
+            $table->string('website')->nullable();
21
+            $table->string('phone', 20)->nullable();
22
+            $table->text('address')->nullable();
23
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
24
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
25
+            $table->timestamps();
26
+        });
27
+
28
+        Schema::create('account_subtypes', function (Blueprint $table) {
29
+            $table->id();
30
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
31
+            $table->boolean('multi_currency')->default(false);
32
+            $table->string('category');
33
+            $table->string('type');
34
+            $table->string('name');
35
+            $table->text('description')->nullable();
36
+            $table->timestamps();
37
+        });
38
+
39
+        Schema::create('accounts', function (Blueprint $table) {
40
+            $table->id();
41
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
42
+            $table->foreignId('subtype_id')->nullable()->constrained('account_subtypes')->nullOnDelete();
43
+            $table->foreignId('parent_id')->nullable()->constrained('accounts')->nullOnDelete();
44
+            $table->string('category')->nullable();
45
+            $table->string('type')->nullable();
46
+            $table->string('code')->nullable()->index();
47
+            $table->string('name')->nullable()->index();
48
+            $table->string('currency_code')->nullable();
49
+            $table->bigInteger('starting_balance')->default(0);
50
+            $table->bigInteger('debit_balance')->default(0);
51
+            $table->bigInteger('credit_balance')->default(0);
52
+            $table->bigInteger('ending_balance')->default(0);
53
+            $table->text('description')->nullable();
54
+            $table->boolean('active')->default(true);
55
+            $table->boolean('default')->default(false);
56
+            $table->unsignedBigInteger('accountable_id')->nullable();
57
+            $table->string('accountable_type')->nullable();
58
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
59
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
60
+            $table->timestamps();
61
+
62
+            $table->unique(['company_id', 'code']);
63
+        });
64
+
65
+        Schema::create('bank_accounts', function (Blueprint $table) {
66
+            $table->id();
67
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
68
+            $table->foreignId('institution_id')->nullable()->constrained('institutions')->nullOnDelete();
69
+            $table->string('type')->default(BankAccountType::DEFAULT);
70
+            $table->string('number', 20);
71
+            $table->boolean('enabled')->default(true);
72
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
73
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
74
+            $table->timestamps();
75
+        });
76
+
77
+        Schema::create('connected_bank_accounts', function (Blueprint $table) {
78
+            $table->id();
79
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
80
+            $table->foreignId('institution_id')->nullable()->constrained('institutions')->nullOnDelete();
81
+            $table->foreignId('bank_account_id')->nullable()->constrained('bank_accounts')->nullOnDelete();
82
+            $table->string('external_account_id')->nullable();
83
+            $table->text('access_token')->nullable();
84
+            $table->string('identifier')->unique()->nullable(); // Plaid
85
+            $table->string('item_id')->nullable();
86
+            $table->string('name');
87
+            $table->string('mask');
88
+            $table->string('type')->default(BankAccountType::DEFAULT);
89
+            $table->string('subtype')->nullable();
90
+            $table->boolean('import_transactions')->default(false);
91
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
92
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
93
+            $table->timestamps();
94
+        });
95
+    }
96
+
97
+    /**
98
+     * Reverse the migrations.
99
+     */
100
+    public function down(): void
101
+    {
102
+        Schema::dropIfExists('institutions');
103
+        Schema::dropIfExists('account_subtypes');
104
+        Schema::dropIfExists('accounts');
105
+        Schema::dropIfExists('bank_accounts');
106
+        Schema::dropIfExists('connected_bank_accounts');
107
+    }
108
+};

+ 0
- 57
database/migrations/2023_09_04_103821_create_accounts_table.php Bestand weergeven

@@ -1,57 +0,0 @@
1
-<?php
2
-
3
-use App\Enums\AccountStatus;
4
-use App\Enums\AccountType;
5
-use Illuminate\Database\Migrations\Migration;
6
-use Illuminate\Database\Schema\Blueprint;
7
-use Illuminate\Support\Facades\Schema;
8
-
9
-return new class extends Migration
10
-{
11
-    /**
12
-     * Run the migrations.
13
-     */
14
-    public function up(): void
15
-    {
16
-        Schema::create('accounts', function (Blueprint $table) {
17
-            $table->id();
18
-            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
19
-            $table->string('type')->default(AccountType::DEFAULT);
20
-            $table->string('name', 100)->index();
21
-            $table->string('number', 20);
22
-            $table->string('currency_code');
23
-            $table->bigInteger('opening_balance')->default(0);
24
-            $table->bigInteger('balance')->default(0);
25
-            $table->string('description')->nullable();
26
-            $table->text('notes')->nullable();
27
-            $table->string('status')->default(AccountStatus::DEFAULT);
28
-            $table->string('bank_name', 100)->nullable();
29
-            $table->string('bank_phone', 20)->nullable();
30
-            $table->text('bank_address')->nullable();
31
-            $table->string('bank_website', 255)->nullable();
32
-            $table->string('bic_swift_code', 11)->nullable();
33
-            $table->string('iban', 34)->nullable();
34
-            $table->string('aba_routing_number', 9)->nullable();
35
-            $table->string('ach_routing_number', 9)->nullable();
36
-            $table->boolean('enabled')->default(true);
37
-            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
38
-            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
39
-            $table->timestamps();
40
-
41
-            $table->unique(['company_id', 'number']);
42
-
43
-            $table->foreign(['company_id', 'currency_code'])
44
-                ->references(['company_id', 'code'])
45
-                ->on('currencies')
46
-                ->restrictOnDelete();
47
-        });
48
-    }
49
-
50
-    /**
51
-     * Reverse the migrations.
52
-     */
53
-    public function down(): void
54
-    {
55
-        Schema::dropIfExists('accounts');
56
-    }
57
-};

+ 2
- 0
database/migrations/2023_09_07_193412_create_categories_table.php Bestand weergeven

@@ -14,10 +14,12 @@ return new class extends Migration
14 14
         Schema::create('categories', function (Blueprint $table) {
15 15
             $table->id();
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('account_id')->nullable()->constrained()->nullOnDelete();
17 18
             $table->string('name')->index();
18 19
             $table->string('type');
19 20
             $table->string('color');
20 21
             $table->boolean('enabled')->default(true);
22
+            $table->boolean('is_default')->default(false);
21 23
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
22 24
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
23 25
             $table->timestamps();

+ 8
- 8
database/migrations/2023_09_08_040159_create_company_defaults_table.php Bestand weergeven

@@ -13,15 +13,15 @@ return new class extends Migration
13 13
     {
14 14
         Schema::create('company_defaults', function (Blueprint $table) {
15 15
             $table->id();
16
-            $table->foreignId('company_id')->constrained()->onDelete('cascade');
17
-            $table->foreignId('account_id')->nullable()->constrained('accounts')->restrictOnDelete();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('bank_account_id')->nullable()->constrained('bank_accounts')->restrictOnDelete();
18 18
             $table->string('currency_code')->nullable();
19
-            $table->foreignId('sales_tax_id')->nullable()->constrained('taxes')->restrictOnDelete();
20
-            $table->foreignId('purchase_tax_id')->nullable()->constrained('taxes')->restrictOnDelete();
21
-            $table->foreignId('sales_discount_id')->nullable()->constrained('discounts')->restrictOnDelete();
22
-            $table->foreignId('purchase_discount_id')->nullable()->constrained('discounts')->restrictOnDelete();
23
-            $table->foreignId('income_category_id')->nullable()->constrained('categories')->restrictOnDelete();
24
-            $table->foreignId('expense_category_id')->nullable()->constrained('categories')->restrictOnDelete();
19
+            $table->foreignId('sales_tax_id')->nullable()->constrained('taxes')->cascadeOnDelete();
20
+            $table->foreignId('purchase_tax_id')->nullable()->constrained('taxes')->cascadeOnDelete();
21
+            $table->foreignId('sales_discount_id')->nullable()->constrained('discounts')->cascadeOnDelete();
22
+            $table->foreignId('purchase_discount_id')->nullable()->constrained('discounts')->cascadeOnDelete();
23
+            $table->foreignId('income_category_id')->nullable()->constrained('categories')->cascadeOnDelete();
24
+            $table->foreignId('expense_category_id')->nullable()->constrained('categories')->cascadeOnDelete();
25 25
             $table->foreignId('created_by')->nullable()->constrained('users')->restrictOnDelete();
26 26
             $table->foreignId('updated_by')->nullable()->constrained('users')->restrictOnDelete();
27 27
             $table->timestamps();

+ 42
- 0
database/migrations/2024_01_01_234943_create_transactions_table.php Bestand weergeven

@@ -0,0 +1,42 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('transactions', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('category_id')->nullable()->constrained()->nullOnDelete();
18
+            $table->foreignId('contact_id')->nullable()->constrained()->nullOnDelete();
19
+            $table->string('type'); // income, expense, other
20
+            $table->string('method'); // deposit, withdrawal
21
+            $table->string('payment_channel')->nullable(); // online, in store, other
22
+            $table->string('description')->nullable();
23
+            $table->text('notes')->nullable();
24
+            $table->string('reference')->nullable();
25
+            $table->bigInteger('amount')->default(0);
26
+            $table->boolean('pending')->default(false);
27
+            $table->boolean('reviewed')->default(false);
28
+            $table->dateTime('posted_at');
29
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
30
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
31
+            $table->timestamps();
32
+        });
33
+    }
34
+
35
+    /**
36
+     * Reverse the migrations.
37
+     */
38
+    public function down(): void
39
+    {
40
+        Schema::dropIfExists('transactions');
41
+    }
42
+};

+ 35
- 0
database/migrations/2024_01_11_062614_create_journal_entries_table.php Bestand weergeven

@@ -0,0 +1,35 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('journal_entries', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('account_id')->constrained()->cascadeOnDelete();
18
+            $table->foreignId('transaction_id')->constrained()->cascadeOnDelete();
19
+            $table->string('type'); // debit, credit
20
+            $table->bigInteger('amount')->default(0);
21
+            $table->text('description')->nullable();
22
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
23
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
24
+            $table->timestamps();
25
+        });
26
+    }
27
+
28
+    /**
29
+     * Reverse the migrations.
30
+     */
31
+    public function down(): void
32
+    {
33
+        Schema::dropIfExists('journal_entries');
34
+    }
35
+};

+ 501
- 130
package-lock.json Bestand weergeven

@@ -379,6 +379,23 @@
379 379
                 "node": ">=12"
380 380
             }
381 381
         },
382
+        "node_modules/@isaacs/cliui": {
383
+            "version": "8.0.2",
384
+            "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
385
+            "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
386
+            "dev": true,
387
+            "dependencies": {
388
+                "string-width": "^5.1.2",
389
+                "string-width-cjs": "npm:string-width@^4.2.0",
390
+                "strip-ansi": "^7.0.1",
391
+                "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
392
+                "wrap-ansi": "^8.1.0",
393
+                "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
394
+            },
395
+            "engines": {
396
+                "node": ">=12"
397
+            }
398
+        },
382 399
         "node_modules/@jridgewell/gen-mapping": {
383 400
             "version": "0.3.3",
384 401
             "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
@@ -418,9 +435,9 @@
418 435
             "dev": true
419 436
         },
420 437
         "node_modules/@jridgewell/trace-mapping": {
421
-            "version": "0.3.20",
422
-            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz",
423
-            "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==",
438
+            "version": "0.3.22",
439
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz",
440
+            "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==",
424 441
             "dev": true,
425 442
             "dependencies": {
426 443
                 "@jridgewell/resolve-uri": "^3.1.0",
@@ -462,6 +479,16 @@
462 479
                 "node": ">= 8"
463 480
             }
464 481
         },
482
+        "node_modules/@pkgjs/parseargs": {
483
+            "version": "0.11.0",
484
+            "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
485
+            "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
486
+            "dev": true,
487
+            "optional": true,
488
+            "engines": {
489
+                "node": ">=14"
490
+            }
491
+        },
465 492
         "node_modules/@tailwindcss/forms": {
466 493
             "version": "0.5.7",
467 494
             "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.7.tgz",
@@ -489,6 +516,30 @@
489 516
                 "tailwindcss": ">=3.0.0 || insiders"
490 517
             }
491 518
         },
519
+        "node_modules/ansi-regex": {
520
+            "version": "6.0.1",
521
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
522
+            "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
523
+            "dev": true,
524
+            "engines": {
525
+                "node": ">=12"
526
+            },
527
+            "funding": {
528
+                "url": "https://github.com/chalk/ansi-regex?sponsor=1"
529
+            }
530
+        },
531
+        "node_modules/ansi-styles": {
532
+            "version": "6.2.1",
533
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
534
+            "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
535
+            "dev": true,
536
+            "engines": {
537
+                "node": ">=12"
538
+            },
539
+            "funding": {
540
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
541
+            }
542
+        },
492 543
         "node_modules/any-promise": {
493 544
             "version": "1.3.0",
494 545
             "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -521,9 +572,9 @@
521 572
             "dev": true
522 573
         },
523 574
         "node_modules/autoprefixer": {
524
-            "version": "10.4.16",
525
-            "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz",
526
-            "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==",
575
+            "version": "10.4.17",
576
+            "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz",
577
+            "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==",
527 578
             "dev": true,
528 579
             "funding": [
529 580
                 {
@@ -540,9 +591,9 @@
540 591
                 }
541 592
             ],
542 593
             "dependencies": {
543
-                "browserslist": "^4.21.10",
544
-                "caniuse-lite": "^1.0.30001538",
545
-                "fraction.js": "^4.3.6",
594
+                "browserslist": "^4.22.2",
595
+                "caniuse-lite": "^1.0.30001578",
596
+                "fraction.js": "^4.3.7",
546 597
                 "normalize-range": "^0.1.2",
547 598
                 "picocolors": "^1.0.0",
548 599
                 "postcss-value-parser": "^4.2.0"
@@ -558,12 +609,12 @@
558 609
             }
559 610
         },
560 611
         "node_modules/axios": {
561
-            "version": "1.6.2",
562
-            "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
563
-            "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
612
+            "version": "1.6.7",
613
+            "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz",
614
+            "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==",
564 615
             "dev": true,
565 616
             "dependencies": {
566
-                "follow-redirects": "^1.15.0",
617
+                "follow-redirects": "^1.15.4",
567 618
                 "form-data": "^4.0.0",
568 619
                 "proxy-from-env": "^1.1.0"
569 620
             }
@@ -584,13 +635,12 @@
584 635
             }
585 636
         },
586 637
         "node_modules/brace-expansion": {
587
-            "version": "1.1.11",
588
-            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
589
-            "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
638
+            "version": "2.0.1",
639
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
640
+            "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
590 641
             "dev": true,
591 642
             "dependencies": {
592
-                "balanced-match": "^1.0.0",
593
-                "concat-map": "0.0.1"
643
+                "balanced-match": "^1.0.0"
594 644
             }
595 645
         },
596 646
         "node_modules/braces": {
@@ -606,9 +656,9 @@
606 656
             }
607 657
         },
608 658
         "node_modules/browserslist": {
609
-            "version": "4.22.2",
610
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz",
611
-            "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==",
659
+            "version": "4.22.3",
660
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz",
661
+            "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==",
612 662
             "dev": true,
613 663
             "funding": [
614 664
                 {
@@ -625,8 +675,8 @@
625 675
                 }
626 676
             ],
627 677
             "dependencies": {
628
-                "caniuse-lite": "^1.0.30001565",
629
-                "electron-to-chromium": "^1.4.601",
678
+                "caniuse-lite": "^1.0.30001580",
679
+                "electron-to-chromium": "^1.4.648",
630 680
                 "node-releases": "^2.0.14",
631 681
                 "update-browserslist-db": "^1.0.13"
632 682
             },
@@ -647,9 +697,9 @@
647 697
             }
648 698
         },
649 699
         "node_modules/caniuse-lite": {
650
-            "version": "1.0.30001566",
651
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001566.tgz",
652
-            "integrity": "sha512-ggIhCsTxmITBAMmK8yZjEhCO5/47jKXPu6Dha/wuCS4JePVL+3uiDEBuhu2aIoT+bqTOR8L76Ip1ARL9xYsEJA==",
700
+            "version": "1.0.30001587",
701
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001587.tgz",
702
+            "integrity": "sha512-HMFNotUmLXn71BQxg8cijvqxnIAofforZOwGsxyXJ0qugTdspUF4sPSJ2vhgprHCB996tIDzEq1ubumPDV8ULA==",
653 703
             "dev": true,
654 704
             "funding": [
655 705
                 {
@@ -667,16 +717,10 @@
667 717
             ]
668 718
         },
669 719
         "node_modules/chokidar": {
670
-            "version": "3.5.3",
671
-            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
672
-            "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==",
720
+            "version": "3.6.0",
721
+            "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
722
+            "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
673 723
             "dev": true,
674
-            "funding": [
675
-                {
676
-                    "type": "individual",
677
-                    "url": "https://paulmillr.com/funding/"
678
-                }
679
-            ],
680 724
             "dependencies": {
681 725
                 "anymatch": "~3.1.2",
682 726
                 "braces": "~3.0.2",
@@ -689,6 +733,9 @@
689 733
             "engines": {
690 734
                 "node": ">= 8.10.0"
691 735
             },
736
+            "funding": {
737
+                "url": "https://paulmillr.com/funding/"
738
+            },
692 739
             "optionalDependencies": {
693 740
                 "fsevents": "~2.3.2"
694 741
             }
@@ -705,6 +752,24 @@
705 752
                 "node": ">= 6"
706 753
             }
707 754
         },
755
+        "node_modules/color-convert": {
756
+            "version": "2.0.1",
757
+            "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
758
+            "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
759
+            "dev": true,
760
+            "dependencies": {
761
+                "color-name": "~1.1.4"
762
+            },
763
+            "engines": {
764
+                "node": ">=7.0.0"
765
+            }
766
+        },
767
+        "node_modules/color-name": {
768
+            "version": "1.1.4",
769
+            "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
770
+            "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
771
+            "dev": true
772
+        },
708 773
         "node_modules/combined-stream": {
709 774
             "version": "1.0.8",
710 775
             "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -726,11 +791,19 @@
726 791
                 "node": ">= 6"
727 792
             }
728 793
         },
729
-        "node_modules/concat-map": {
730
-            "version": "0.0.1",
731
-            "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
732
-            "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
733
-            "dev": true
794
+        "node_modules/cross-spawn": {
795
+            "version": "7.0.3",
796
+            "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
797
+            "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
798
+            "dev": true,
799
+            "dependencies": {
800
+                "path-key": "^3.1.0",
801
+                "shebang-command": "^2.0.0",
802
+                "which": "^2.0.1"
803
+            },
804
+            "engines": {
805
+                "node": ">= 8"
806
+            }
734 807
         },
735 808
         "node_modules/cssesc": {
736 809
             "version": "3.0.0",
@@ -765,10 +838,22 @@
765 838
             "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
766 839
             "dev": true
767 840
         },
841
+        "node_modules/eastasianwidth": {
842
+            "version": "0.2.0",
843
+            "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
844
+            "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
845
+            "dev": true
846
+        },
768 847
         "node_modules/electron-to-chromium": {
769
-            "version": "1.4.607",
770
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.607.tgz",
771
-            "integrity": "sha512-YUlnPwE6eYxzwBnFmawA8LiLRfm70R2aJRIUv0n03uHt/cUzzYACOogmvk8M2+hVzt/kB80KJXx7d5f5JofPvQ==",
848
+            "version": "1.4.668",
849
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.668.tgz",
850
+            "integrity": "sha512-ZOBocMYCehr9W31+GpMclR+KBaDZOoAEabLdhpZ8oU1JFDwIaFY0UDbpXVEUFc0BIP2O2Qn3rkfCjQmMR4T/bQ==",
851
+            "dev": true
852
+        },
853
+        "node_modules/emoji-regex": {
854
+            "version": "9.2.2",
855
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
856
+            "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
772 857
             "dev": true
773 858
         },
774 859
         "node_modules/esbuild": {
@@ -809,9 +894,9 @@
809 894
             }
810 895
         },
811 896
         "node_modules/escalade": {
812
-            "version": "3.1.1",
813
-            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
814
-            "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
897
+            "version": "3.1.2",
898
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
899
+            "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==",
815 900
             "dev": true,
816 901
             "engines": {
817 902
                 "node": ">=6"
@@ -846,9 +931,9 @@
846 931
             }
847 932
         },
848 933
         "node_modules/fastq": {
849
-            "version": "1.15.0",
850
-            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
851
-            "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
934
+            "version": "1.17.1",
935
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
936
+            "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
852 937
             "dev": true,
853 938
             "dependencies": {
854 939
                 "reusify": "^1.0.4"
@@ -867,9 +952,9 @@
867 952
             }
868 953
         },
869 954
         "node_modules/follow-redirects": {
870
-            "version": "1.15.3",
871
-            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
872
-            "integrity": "sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==",
955
+            "version": "1.15.5",
956
+            "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
957
+            "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
873 958
             "dev": true,
874 959
             "funding": [
875 960
                 {
@@ -886,6 +971,22 @@
886 971
                 }
887 972
             }
888 973
         },
974
+        "node_modules/foreground-child": {
975
+            "version": "3.1.1",
976
+            "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
977
+            "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
978
+            "dev": true,
979
+            "dependencies": {
980
+                "cross-spawn": "^7.0.0",
981
+                "signal-exit": "^4.0.1"
982
+            },
983
+            "engines": {
984
+                "node": ">=14"
985
+            },
986
+            "funding": {
987
+                "url": "https://github.com/sponsors/isaacs"
988
+            }
989
+        },
889 990
         "node_modules/form-data": {
890 991
             "version": "4.0.0",
891 992
             "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
@@ -913,12 +1014,6 @@
913 1014
                 "url": "https://github.com/sponsors/rawify"
914 1015
             }
915 1016
         },
916
-        "node_modules/fs.realpath": {
917
-            "version": "1.0.0",
918
-            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
919
-            "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
920
-            "dev": true
921
-        },
922 1017
         "node_modules/fsevents": {
923 1018
             "version": "2.3.3",
924 1019
             "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -943,20 +1038,22 @@
943 1038
             }
944 1039
         },
945 1040
         "node_modules/glob": {
946
-            "version": "7.1.6",
947
-            "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
948
-            "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
1041
+            "version": "10.3.10",
1042
+            "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz",
1043
+            "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==",
949 1044
             "dev": true,
950 1045
             "dependencies": {
951
-                "fs.realpath": "^1.0.0",
952
-                "inflight": "^1.0.4",
953
-                "inherits": "2",
954
-                "minimatch": "^3.0.4",
955
-                "once": "^1.3.0",
956
-                "path-is-absolute": "^1.0.0"
1046
+                "foreground-child": "^3.1.0",
1047
+                "jackspeak": "^2.3.5",
1048
+                "minimatch": "^9.0.1",
1049
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0",
1050
+                "path-scurry": "^1.10.1"
1051
+            },
1052
+            "bin": {
1053
+                "glob": "dist/esm/bin.mjs"
957 1054
             },
958 1055
             "engines": {
959
-                "node": "*"
1056
+                "node": ">=16 || 14 >=14.17"
960 1057
             },
961 1058
             "funding": {
962 1059
                 "url": "https://github.com/sponsors/isaacs"
@@ -975,9 +1072,9 @@
975 1072
             }
976 1073
         },
977 1074
         "node_modules/hasown": {
978
-            "version": "2.0.0",
979
-            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz",
980
-            "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==",
1075
+            "version": "2.0.1",
1076
+            "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz",
1077
+            "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==",
981 1078
             "dev": true,
982 1079
             "dependencies": {
983 1080
                 "function-bind": "^1.1.2"
@@ -986,22 +1083,6 @@
986 1083
                 "node": ">= 0.4"
987 1084
             }
988 1085
         },
989
-        "node_modules/inflight": {
990
-            "version": "1.0.6",
991
-            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
992
-            "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
993
-            "dev": true,
994
-            "dependencies": {
995
-                "once": "^1.3.0",
996
-                "wrappy": "1"
997
-            }
998
-        },
999
-        "node_modules/inherits": {
1000
-            "version": "2.0.4",
1001
-            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
1002
-            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1003
-            "dev": true
1004
-        },
1005 1086
         "node_modules/is-binary-path": {
1006 1087
             "version": "2.1.0",
1007 1088
             "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1035,6 +1116,15 @@
1035 1116
                 "node": ">=0.10.0"
1036 1117
             }
1037 1118
         },
1119
+        "node_modules/is-fullwidth-code-point": {
1120
+            "version": "3.0.0",
1121
+            "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
1122
+            "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
1123
+            "dev": true,
1124
+            "engines": {
1125
+                "node": ">=8"
1126
+            }
1127
+        },
1038 1128
         "node_modules/is-glob": {
1039 1129
             "version": "4.0.3",
1040 1130
             "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1056,6 +1146,30 @@
1056 1146
                 "node": ">=0.12.0"
1057 1147
             }
1058 1148
         },
1149
+        "node_modules/isexe": {
1150
+            "version": "2.0.0",
1151
+            "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
1152
+            "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
1153
+            "dev": true
1154
+        },
1155
+        "node_modules/jackspeak": {
1156
+            "version": "2.3.6",
1157
+            "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz",
1158
+            "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==",
1159
+            "dev": true,
1160
+            "dependencies": {
1161
+                "@isaacs/cliui": "^8.0.2"
1162
+            },
1163
+            "engines": {
1164
+                "node": ">=14"
1165
+            },
1166
+            "funding": {
1167
+                "url": "https://github.com/sponsors/isaacs"
1168
+            },
1169
+            "optionalDependencies": {
1170
+                "@pkgjs/parseargs": "^0.11.0"
1171
+            }
1172
+        },
1059 1173
         "node_modules/jiti": {
1060 1174
             "version": "1.21.0",
1061 1175
             "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz",
@@ -1114,6 +1228,15 @@
1114 1228
             "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
1115 1229
             "dev": true
1116 1230
         },
1231
+        "node_modules/lru-cache": {
1232
+            "version": "10.2.0",
1233
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz",
1234
+            "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==",
1235
+            "dev": true,
1236
+            "engines": {
1237
+                "node": "14 || >=16.14"
1238
+            }
1239
+        },
1117 1240
         "node_modules/merge2": {
1118 1241
             "version": "1.4.1",
1119 1242
             "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -1167,15 +1290,27 @@
1167 1290
             }
1168 1291
         },
1169 1292
         "node_modules/minimatch": {
1170
-            "version": "3.1.2",
1171
-            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
1172
-            "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
1293
+            "version": "9.0.3",
1294
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
1295
+            "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
1173 1296
             "dev": true,
1174 1297
             "dependencies": {
1175
-                "brace-expansion": "^1.1.7"
1298
+                "brace-expansion": "^2.0.1"
1176 1299
             },
1177 1300
             "engines": {
1178
-                "node": "*"
1301
+                "node": ">=16 || 14 >=14.17"
1302
+            },
1303
+            "funding": {
1304
+                "url": "https://github.com/sponsors/isaacs"
1305
+            }
1306
+        },
1307
+        "node_modules/minipass": {
1308
+            "version": "7.0.4",
1309
+            "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz",
1310
+            "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==",
1311
+            "dev": true,
1312
+            "engines": {
1313
+                "node": ">=16 || 14 >=14.17"
1179 1314
             }
1180 1315
         },
1181 1316
         "node_modules/mz": {
@@ -1249,22 +1384,13 @@
1249 1384
                 "node": ">= 6"
1250 1385
             }
1251 1386
         },
1252
-        "node_modules/once": {
1253
-            "version": "1.4.0",
1254
-            "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
1255
-            "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
1256
-            "dev": true,
1257
-            "dependencies": {
1258
-                "wrappy": "1"
1259
-            }
1260
-        },
1261
-        "node_modules/path-is-absolute": {
1262
-            "version": "1.0.1",
1263
-            "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
1264
-            "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
1387
+        "node_modules/path-key": {
1388
+            "version": "3.1.1",
1389
+            "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
1390
+            "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
1265 1391
             "dev": true,
1266 1392
             "engines": {
1267
-                "node": ">=0.10.0"
1393
+                "node": ">=8"
1268 1394
             }
1269 1395
         },
1270 1396
         "node_modules/path-parse": {
@@ -1273,6 +1399,22 @@
1273 1399
             "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
1274 1400
             "dev": true
1275 1401
         },
1402
+        "node_modules/path-scurry": {
1403
+            "version": "1.10.1",
1404
+            "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz",
1405
+            "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==",
1406
+            "dev": true,
1407
+            "dependencies": {
1408
+                "lru-cache": "^9.1.1 || ^10.0.0",
1409
+                "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
1410
+            },
1411
+            "engines": {
1412
+                "node": ">=16 || 14 >=14.17"
1413
+            },
1414
+            "funding": {
1415
+                "url": "https://github.com/sponsors/isaacs"
1416
+            }
1417
+        },
1276 1418
         "node_modules/picocolors": {
1277 1419
             "version": "1.0.0",
1278 1420
             "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -1310,9 +1452,9 @@
1310 1452
             }
1311 1453
         },
1312 1454
         "node_modules/postcss": {
1313
-            "version": "8.4.32",
1314
-            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
1315
-            "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
1455
+            "version": "8.4.35",
1456
+            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
1457
+            "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
1316 1458
             "dev": true,
1317 1459
             "funding": [
1318 1460
                 {
@@ -1437,9 +1579,9 @@
1437 1579
             }
1438 1580
         },
1439 1581
         "node_modules/postcss-nested/node_modules/postcss-selector-parser": {
1440
-            "version": "6.0.13",
1441
-            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
1442
-            "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
1582
+            "version": "6.0.15",
1583
+            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz",
1584
+            "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==",
1443 1585
             "dev": true,
1444 1586
             "dependencies": {
1445 1587
                 "cssesc": "^3.0.0",
@@ -1581,6 +1723,39 @@
1581 1723
                 "queue-microtask": "^1.2.2"
1582 1724
             }
1583 1725
         },
1726
+        "node_modules/shebang-command": {
1727
+            "version": "2.0.0",
1728
+            "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
1729
+            "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
1730
+            "dev": true,
1731
+            "dependencies": {
1732
+                "shebang-regex": "^3.0.0"
1733
+            },
1734
+            "engines": {
1735
+                "node": ">=8"
1736
+            }
1737
+        },
1738
+        "node_modules/shebang-regex": {
1739
+            "version": "3.0.0",
1740
+            "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
1741
+            "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
1742
+            "dev": true,
1743
+            "engines": {
1744
+                "node": ">=8"
1745
+            }
1746
+        },
1747
+        "node_modules/signal-exit": {
1748
+            "version": "4.1.0",
1749
+            "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
1750
+            "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
1751
+            "dev": true,
1752
+            "engines": {
1753
+                "node": ">=14"
1754
+            },
1755
+            "funding": {
1756
+                "url": "https://github.com/sponsors/isaacs"
1757
+            }
1758
+        },
1584 1759
         "node_modules/source-map-js": {
1585 1760
             "version": "1.0.2",
1586 1761
             "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@@ -1590,15 +1765,111 @@
1590 1765
                 "node": ">=0.10.0"
1591 1766
             }
1592 1767
         },
1768
+        "node_modules/string-width": {
1769
+            "version": "5.1.2",
1770
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
1771
+            "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
1772
+            "dev": true,
1773
+            "dependencies": {
1774
+                "eastasianwidth": "^0.2.0",
1775
+                "emoji-regex": "^9.2.2",
1776
+                "strip-ansi": "^7.0.1"
1777
+            },
1778
+            "engines": {
1779
+                "node": ">=12"
1780
+            },
1781
+            "funding": {
1782
+                "url": "https://github.com/sponsors/sindresorhus"
1783
+            }
1784
+        },
1785
+        "node_modules/string-width-cjs": {
1786
+            "name": "string-width",
1787
+            "version": "4.2.3",
1788
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
1789
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
1790
+            "dev": true,
1791
+            "dependencies": {
1792
+                "emoji-regex": "^8.0.0",
1793
+                "is-fullwidth-code-point": "^3.0.0",
1794
+                "strip-ansi": "^6.0.1"
1795
+            },
1796
+            "engines": {
1797
+                "node": ">=8"
1798
+            }
1799
+        },
1800
+        "node_modules/string-width-cjs/node_modules/ansi-regex": {
1801
+            "version": "5.0.1",
1802
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
1803
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1804
+            "dev": true,
1805
+            "engines": {
1806
+                "node": ">=8"
1807
+            }
1808
+        },
1809
+        "node_modules/string-width-cjs/node_modules/emoji-regex": {
1810
+            "version": "8.0.0",
1811
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
1812
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1813
+            "dev": true
1814
+        },
1815
+        "node_modules/string-width-cjs/node_modules/strip-ansi": {
1816
+            "version": "6.0.1",
1817
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
1818
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
1819
+            "dev": true,
1820
+            "dependencies": {
1821
+                "ansi-regex": "^5.0.1"
1822
+            },
1823
+            "engines": {
1824
+                "node": ">=8"
1825
+            }
1826
+        },
1827
+        "node_modules/strip-ansi": {
1828
+            "version": "7.1.0",
1829
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
1830
+            "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
1831
+            "dev": true,
1832
+            "dependencies": {
1833
+                "ansi-regex": "^6.0.1"
1834
+            },
1835
+            "engines": {
1836
+                "node": ">=12"
1837
+            },
1838
+            "funding": {
1839
+                "url": "https://github.com/chalk/strip-ansi?sponsor=1"
1840
+            }
1841
+        },
1842
+        "node_modules/strip-ansi-cjs": {
1843
+            "name": "strip-ansi",
1844
+            "version": "6.0.1",
1845
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
1846
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
1847
+            "dev": true,
1848
+            "dependencies": {
1849
+                "ansi-regex": "^5.0.1"
1850
+            },
1851
+            "engines": {
1852
+                "node": ">=8"
1853
+            }
1854
+        },
1855
+        "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
1856
+            "version": "5.0.1",
1857
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
1858
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1859
+            "dev": true,
1860
+            "engines": {
1861
+                "node": ">=8"
1862
+            }
1863
+        },
1593 1864
         "node_modules/sucrase": {
1594
-            "version": "3.34.0",
1595
-            "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
1596
-            "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==",
1865
+            "version": "3.35.0",
1866
+            "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
1867
+            "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
1597 1868
             "dev": true,
1598 1869
             "dependencies": {
1599 1870
                 "@jridgewell/gen-mapping": "^0.3.2",
1600 1871
                 "commander": "^4.0.0",
1601
-                "glob": "7.1.6",
1872
+                "glob": "^10.3.10",
1602 1873
                 "lines-and-columns": "^1.1.6",
1603 1874
                 "mz": "^2.7.0",
1604 1875
                 "pirates": "^4.0.1",
@@ -1609,7 +1880,7 @@
1609 1880
                 "sucrase-node": "bin/sucrase-node"
1610 1881
             },
1611 1882
             "engines": {
1612
-                "node": ">=8"
1883
+                "node": ">=16 || 14 >=14.17"
1613 1884
             }
1614 1885
         },
1615 1886
         "node_modules/supports-preserve-symlinks-flag": {
@@ -1625,9 +1896,9 @@
1625 1896
             }
1626 1897
         },
1627 1898
         "node_modules/tailwindcss": {
1628
-            "version": "3.3.6",
1629
-            "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.6.tgz",
1630
-            "integrity": "sha512-AKjF7qbbLvLaPieoKeTjG1+FyNZT6KaJMJPFeQyLfIp7l82ggH1fbHJSsYIvnbTFQOlkh+gBYpyby5GT1LIdLw==",
1899
+            "version": "3.4.1",
1900
+            "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz",
1901
+            "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==",
1631 1902
             "dev": true,
1632 1903
             "dependencies": {
1633 1904
                 "@alloc/quick-lru": "^5.2.0",
@@ -1662,9 +1933,9 @@
1662 1933
             }
1663 1934
         },
1664 1935
         "node_modules/tailwindcss/node_modules/postcss-selector-parser": {
1665
-            "version": "6.0.13",
1666
-            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
1667
-            "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
1936
+            "version": "6.0.15",
1937
+            "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz",
1938
+            "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==",
1668 1939
             "dev": true,
1669 1940
             "dependencies": {
1670 1941
                 "cssesc": "^3.0.0",
@@ -1750,9 +2021,9 @@
1750 2021
             "dev": true
1751 2022
         },
1752 2023
         "node_modules/vite": {
1753
-            "version": "4.5.1",
1754
-            "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz",
1755
-            "integrity": "sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==",
2024
+            "version": "4.5.2",
2025
+            "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz",
2026
+            "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==",
1756 2027
             "dev": true,
1757 2028
             "dependencies": {
1758 2029
                 "esbuild": "^0.18.10",
@@ -1814,12 +2085,112 @@
1814 2085
                 "picomatch": "^2.3.1"
1815 2086
             }
1816 2087
         },
1817
-        "node_modules/wrappy": {
1818
-            "version": "1.0.2",
1819
-            "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
1820
-            "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
2088
+        "node_modules/which": {
2089
+            "version": "2.0.2",
2090
+            "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
2091
+            "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
2092
+            "dev": true,
2093
+            "dependencies": {
2094
+                "isexe": "^2.0.0"
2095
+            },
2096
+            "bin": {
2097
+                "node-which": "bin/node-which"
2098
+            },
2099
+            "engines": {
2100
+                "node": ">= 8"
2101
+            }
2102
+        },
2103
+        "node_modules/wrap-ansi": {
2104
+            "version": "8.1.0",
2105
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
2106
+            "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
2107
+            "dev": true,
2108
+            "dependencies": {
2109
+                "ansi-styles": "^6.1.0",
2110
+                "string-width": "^5.0.1",
2111
+                "strip-ansi": "^7.0.1"
2112
+            },
2113
+            "engines": {
2114
+                "node": ">=12"
2115
+            },
2116
+            "funding": {
2117
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
2118
+            }
2119
+        },
2120
+        "node_modules/wrap-ansi-cjs": {
2121
+            "name": "wrap-ansi",
2122
+            "version": "7.0.0",
2123
+            "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
2124
+            "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
2125
+            "dev": true,
2126
+            "dependencies": {
2127
+                "ansi-styles": "^4.0.0",
2128
+                "string-width": "^4.1.0",
2129
+                "strip-ansi": "^6.0.0"
2130
+            },
2131
+            "engines": {
2132
+                "node": ">=10"
2133
+            },
2134
+            "funding": {
2135
+                "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
2136
+            }
2137
+        },
2138
+        "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
2139
+            "version": "5.0.1",
2140
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
2141
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
2142
+            "dev": true,
2143
+            "engines": {
2144
+                "node": ">=8"
2145
+            }
2146
+        },
2147
+        "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
2148
+            "version": "4.3.0",
2149
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
2150
+            "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
2151
+            "dev": true,
2152
+            "dependencies": {
2153
+                "color-convert": "^2.0.1"
2154
+            },
2155
+            "engines": {
2156
+                "node": ">=8"
2157
+            },
2158
+            "funding": {
2159
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
2160
+            }
2161
+        },
2162
+        "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
2163
+            "version": "8.0.0",
2164
+            "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
2165
+            "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1821 2166
             "dev": true
1822 2167
         },
2168
+        "node_modules/wrap-ansi-cjs/node_modules/string-width": {
2169
+            "version": "4.2.3",
2170
+            "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
2171
+            "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
2172
+            "dev": true,
2173
+            "dependencies": {
2174
+                "emoji-regex": "^8.0.0",
2175
+                "is-fullwidth-code-point": "^3.0.0",
2176
+                "strip-ansi": "^6.0.1"
2177
+            },
2178
+            "engines": {
2179
+                "node": ">=8"
2180
+            }
2181
+        },
2182
+        "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
2183
+            "version": "6.0.1",
2184
+            "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
2185
+            "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
2186
+            "dev": true,
2187
+            "dependencies": {
2188
+                "ansi-regex": "^5.0.1"
2189
+            },
2190
+            "engines": {
2191
+                "node": ">=8"
2192
+            }
2193
+        },
1823 2194
         "node_modules/yaml": {
1824 2195
             "version": "2.3.4",
1825 2196
             "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz",

+ 5
- 2
resources/css/filament/company/tailwind.config.js Bestand weergeven

@@ -5,16 +5,19 @@ export default {
5 5
     content: [
6 6
         './app/Filament/Company/**/*.php',
7 7
         './resources/views/filament/company/**/*.blade.php',
8
-        './resources/views/components/company/**/*.blade.php',
8
+        './resources/views/livewire/company/**/*.blade.php',
9
+        './resources/views/components/**/*.blade.php',
9 10
         './vendor/filament/**/*.blade.php',
10 11
         './vendor/andrewdwallo/filament-companies/resources/views/**/*.blade.php',
11 12
         './vendor/andrewdwallo/filament-selectify/resources/views/**/*.blade.php',
13
+        './resources/views/vendor/**/*.blade.php',
12 14
         './vendor/bezhansalleh/filament-panel-switch/resources/views/panel-switch-menu.blade.php',
13 15
     ],
14 16
     theme: {
15 17
         extend: {
16 18
             colors: {
17
-                white: '#F6F5F3',
19
+                white: '#F3F4F6',
20
+                platinum: '#E8E9EB',
18 21
             },
19 22
         }
20 23
     }

+ 51
- 1
resources/css/filament/company/theme.css Bestand weergeven

@@ -1,7 +1,57 @@
1 1
 @import '../../../../vendor/filament/filament/resources/css/theme.css';
2
+@import 'tooltip.css';
2 3
 
3 4
 @config './tailwind.config.js';
4 5
 
6
+.choices:focus-visible {
7
+    outline: none;
8
+}
9
+
10
+.es-table__header-ctn, .es-table__footer-ctn {
11
+    @apply divide-y divide-gray-200 dark:divide-white/10 h-12;
12
+}
13
+
14
+.es-table__row {
15
+    @apply [@media(hover:hover)]:transition [@media(hover:hover)]:duration-75 hover:bg-gray-50 dark:hover:bg-white/5;
16
+}
17
+
18
+.es-table .es-table__rowgroup td:first-child {
19
+    padding-left: 3rem;
20
+}
21
+
22
+.es-table .es-table__rowgroup .es-table__row > td:nth-child(2) {
23
+    word-wrap: break-word;
24
+    word-break: break-word;
25
+    white-space: normal;
26
+}
27
+
28
+.es-table .es-table__rowgroup .es-table__row > td:nth-child(3) {
29
+    word-wrap: break-word;
30
+    word-break: break-word;
31
+    white-space: normal;
32
+}
33
+
34
+.es-table .es-table__rowgroup .es-table__row > td:nth-child(4) {
35
+    white-space: nowrap;
36
+}
37
+
38
+.es-table .es-table__rowgroup .es-table__row > td:last-child {
39
+    padding-right: 3rem;
40
+}
41
+
42
+.choices__group {
43
+    color: rgba(var(--gray-900), 1);
44
+    font-weight: bold;
45
+}
46
+
47
+.connected-account-header .fi-icon-btn.fi-ac-icon-btn-action {
48
+    @apply text-primary-500 hover:text-danger-600 focus-visible:text-danger-600 focus-visible:ring-danger-500 dark:text-primary-400 dark:hover:text-danger-300 dark:focus-visible:text-danger-300 dark:focus-visible:ring-danger-500;
49
+}
50
+
51
+.fi-ta-empty-state-icon-ctn {
52
+    @apply bg-platinum;
53
+}
54
+
5 55
 .fi-body {
6 56
     position: relative;
7 57
     background-color: #E8E9EB;
@@ -193,7 +243,7 @@
193 243
     }
194 244
 }
195 245
 
196
-.choices__inner {
246
+.choices[data-type="select-one"] .choices__inner {
197 247
     height: 2.25rem;
198 248
 }
199 249
 

+ 76
- 0
resources/css/filament/company/tooltip.css Bestand weergeven

@@ -0,0 +1,76 @@
1
+.es-toolip .tippy-arrow {
2
+    border-color: inherit;
3
+}
4
+
5
+.es-tooltip .tippy-box[data-placement^='right'] > .tippy-arrow::before {
6
+    transform: scale(1.75);
7
+}
8
+
9
+.es-tooltip .tippy-box[data-placement^='right'] > .tippy-arrow::after {
10
+    content: "";
11
+    z-index: -1;
12
+    position: absolute;
13
+    border-color: transparent;
14
+    border-style: solid;
15
+    top: 0;
16
+    border-width: 8px 8px 8px 0;
17
+    border-right-color: inherit;
18
+    left: -11px;
19
+    transform: scale(1.75);
20
+}
21
+
22
+
23
+.es-tooltip .tippy-box[data-theme~='light'][data-placement^='right'] {
24
+    border-radius: 8px;
25
+    border: 1px solid #d4dde3;
26
+    box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
27
+}
28
+
29
+.es-tooltip .tippy-box[data-theme~='dark'][data-placement^='right'] {
30
+    border-radius: 8px;
31
+    border: 1px solid #555555;
32
+    box-shadow: 0 4px 16px 0 rgba(0, 0, 0, .7);
33
+}
34
+
35
+.es-close-tooltip {
36
+    cursor: pointer;
37
+    position: absolute;
38
+    top: 8px;
39
+    right: 8px;
40
+    width: 16px;
41
+    height: 16px;
42
+    border: 0;
43
+    background: none;
44
+    padding: 0;
45
+
46
+    &::before {
47
+        transform: rotate(45deg);
48
+    }
49
+
50
+    &::after {
51
+        transform: rotate(-45deg);
52
+    }
53
+
54
+    &::before, &::after {
55
+        content: '';
56
+        position: absolute;
57
+        top: 2px;
58
+        left: 7px;
59
+        width: 2px;
60
+        height: 12px;
61
+        background-color: #92a0aa;
62
+        transition: background-color 0.2s;
63
+    }
64
+}
65
+
66
+.es-close-tooltip:hover::before, .es-close-tooltip:hover::after {
67
+    background-color: #707070;
68
+}
69
+
70
+:is(.dark) .es-close-tooltip::before, :is(.dark) .es-close-tooltip::after {
71
+    background-color: #dddddd;
72
+}
73
+
74
+:is(.dark) .es-close-tooltip:hover::before, :is(.dark) .es-close-tooltip:hover::after {
75
+    background-color: #bbbbbb;
76
+}

+ 1
- 1
resources/css/filament/user/tailwind.config.js Bestand weergeven

@@ -12,7 +12,7 @@ export default {
12 12
     theme: {
13 13
         extend: {
14 14
             colors: {
15
-                white: '#F6F5F3',
15
+                white: '#F3F4F6',
16 16
                 platinum: '#E8E9EB',
17 17
                 moonlight: '#F6F5F3',
18 18
                 'translucent': {

+ 61
- 6
resources/css/filament/user/theme.css Bestand weergeven

@@ -3,7 +3,58 @@
3 3
 @config './tailwind.config.js';
4 4
 
5 5
 .fi-body {
6
-    @apply bg-platinum;
6
+    position: relative;
7
+    background-color: #E8E9EB;
8
+    z-index: 1;
9
+}
10
+
11
+.fi-input-password-revealable::-ms-reveal {
12
+    display: none;
13
+}
14
+
15
+.fi-body::before {
16
+    content: '';
17
+    position: absolute;
18
+    top: 0;
19
+    left: 0;
20
+    width: 100%;
21
+    height: 100%;
22
+    background-image:
23
+        linear-gradient(99.6deg,
24
+            rgba(232, 233, 235, 1) 10.6%,
25
+            rgba(240, 241, 243, 1) 32.9%,
26
+            rgba(248, 249, 251, 0.7) 50%,
27
+            rgba(240, 241, 243, 1) 67.1%,
28
+            rgba(232, 233, 235, 1) 83.4%);
29
+    pointer-events: none;
30
+    z-index: -1;
31
+}
32
+
33
+:is(.dark .fi-body) {
34
+    position: relative;
35
+    background-color: rgb(3, 7, 18);
36
+    z-index: 1;
37
+}
38
+
39
+:is(.dark .fi-body)::before {
40
+    content: '';
41
+    position: absolute;
42
+    top: 0;
43
+    right: 0;
44
+    background-image: radial-gradient(
45
+        ellipse at top right,
46
+        rgba(var(--primary-950), 1) 0%,
47
+        rgba(var(--primary-950), 0.9) 15%,
48
+        rgba(var(--primary-900), 0.7) 30%,
49
+        rgba(var(--primary-900), 0.5) 45%,
50
+        rgba(var(--primary-950), 0.3) 60%,
51
+        rgba(var(--primary-950), 0.1) 75%,
52
+        rgba(3,7,18,0) 100%
53
+    );
54
+    width: 100%;
55
+    height: 100%;
56
+    pointer-events: none;
57
+    z-index: -1;
7 58
 }
8 59
 
9 60
 .fi-topbar > nav, .fi-sidebar-header {
@@ -24,13 +75,17 @@
24 75
 }
25 76
 
26 77
 .fi-topbar > nav.topbar-hovered, .fi-sidebar-header.topbar-hovered {
27
-    @apply bg-translucent dark:bg-translucent-dark !important;
78
+    background-color: rgba(255, 255, 255, 0.75) !important;
79
+}
80
+
81
+:is(.dark .fi-topbar > nav.topbar-hovered, .dark .fi-sidebar-header.topbar-hovered) {
82
+    background-color: rgba(10, 16, 33, 0.75) !important;
28 83
 }
29 84
 
30
-.fi-dropdown.fi-topbar-dropdown .fi-dropdown-panel {
31
-    @apply absolute -translate-x-1/2 left-1/2 !important;
85
+.fi-topbar > nav.topbar-scrolled, .fi-sidebar-header.topbar-scrolled {
86
+    background-color: rgba(255, 255, 255, 0.5) !important;
32 87
 }
33 88
 
34
-.fi-icon-btn.bg-gray-100.\!rounded-full.dark\:bg-custom-500\/20 {
35
-    @apply bg-translucent;
89
+:is(.dark .fi-topbar > nav.topbar-scrolled, .dark .fi-sidebar-header.topbar-scrolled) {
90
+    background-color: rgba(10, 16, 33, 0.5) !important;
36 91
 }

+ 13
- 2
resources/data/lang/ar.json Bestand weergeven

@@ -229,5 +229,16 @@
229 229
     "Website": "الموقع الإلكتروني",
230 230
     "Resources": "الموارد",
231 231
     "Finance": "التمويل",
232
-    "Personalization": "التخصيص"
233
-}
232
+    "Personalization": "التخصيص",
233
+    "Connected": "متصل",
234
+    "Manual": "دليل",
235
+    "Accounts": "الحسابات",
236
+    "Connected Accounts": "الحسابات المتصلة",
237
+    "Subtype": "نوع فرعي",
238
+    "Credit": "الائتمان",
239
+    "Investment": "الاستثمار",
240
+    "Routing Number": "رقم التوجيه",
241
+    "Depository": "جهة الإيداع",
242
+    "Brokerage": "الوساطة",
243
+    "Loan": "قرض"
244
+}

+ 13
- 2
resources/data/lang/de.json Bestand weergeven

@@ -229,5 +229,16 @@
229 229
     "Management": "Verwaltung",
230 230
     "Finance": "Finanzen",
231 231
     "Company": "Firma",
232
-    "Personalization": "Personalisierung"
233
-}
232
+    "Personalization": "Personalisierung",
233
+    "Investment": "Investition",
234
+    "Subtype": "Subtyp",
235
+    "Routing Number": "Routing-Nummer",
236
+    "Credit": "Kredit",
237
+    "Connected Accounts": "Verbundene Konten",
238
+    "Connected": "Verbunden",
239
+    "Accounts": "Konten",
240
+    "Manual": "Manuell",
241
+    "Loan": "Darlehen",
242
+    "Brokerage": "Maklertätigkeit",
243
+    "Depository": "Verwahrstelle"
244
+}

+ 20
- 2
resources/data/lang/en.json Bestand weergeven

@@ -229,5 +229,23 @@
229 229
     "Resources": "Resources",
230 230
     "Finance": "Finance",
231 231
     "Personalization": "Personalization",
232
-    "Company": "Company"
233
-}
232
+    "Company": "Company",
233
+    "Connected Accounts": "Connected Accounts",
234
+    "Subtype": "Subtype",
235
+    "Routing Number": "Routing Number",
236
+    "Investment": "Investment",
237
+    "Credit": "Credit",
238
+    "Depository": "Depository",
239
+    "Loan": "Loan",
240
+    "Brokerage": "Brokerage",
241
+    "Connected": "Connected",
242
+    "Manual": "Manual",
243
+    "Accounts": "Accounts",
244
+    "Accounting": "Accounting",
245
+    "Bank Account": "Bank Account",
246
+    "Starting Balance": "Starting Balance",
247
+    "Account Identification": "Account Identification",
248
+    "Account Settings": "Account Settings",
249
+    "Financial Details": "Financial Details",
250
+    "Oh Yes": "Oh Yes"
251
+}

+ 20
- 2
resources/data/lang/es.json Bestand weergeven

@@ -229,5 +229,23 @@
229 229
     "Website": "Sitio web",
230 230
     "Management": "Administración",
231 231
     "Finance": "Finanzas",
232
-    "Personalization": "Personalización"
233
-}
232
+    "Personalization": "Personalización",
233
+    "Subtype": "Subtipo",
234
+    "Investment": "Inversión",
235
+    "Connected": "Conectado",
236
+    "Loan": "préstamo",
237
+    "Manual": "Manual",
238
+    "Accounts": "Cuentas",
239
+    "Credit": "Crédito",
240
+    "Routing Number": "Número de ruta",
241
+    "Brokerage": "Corretaje",
242
+    "Depository": "Depositario",
243
+    "Connected Accounts": "Cuentas conectadas",
244
+    "Account Identification": "Account Identification",
245
+    "Account Settings": "Account Settings",
246
+    "Financial Details": "Financial Details",
247
+    "Accounting": "Accounting",
248
+    "Bank Account": "Bank Account",
249
+    "Starting Balance": "Starting Balance",
250
+    "Oh Yes": "Oh Yes"
251
+}

+ 13
- 2
resources/data/lang/fr.json Bestand weergeven

@@ -229,5 +229,16 @@
229 229
     "Resources": "Ressources",
230 230
     "Finance": "Finances",
231 231
     "Company": "Entreprise",
232
-    "Personalization": "Personnalisation"
233
-}
232
+    "Personalization": "Personnalisation",
233
+    "Manual": "manuel",
234
+    "Connected": "Connecté",
235
+    "Connected Accounts": "Comptes connectés",
236
+    "Subtype": "Sous-type",
237
+    "Routing Number": "Numéro de routage",
238
+    "Loan": "Prêt",
239
+    "Brokerage": "Courtage",
240
+    "Credit": "Crédit",
241
+    "Investment": "Investissement",
242
+    "Depository": "Dépositaire",
243
+    "Accounts": "Comptes"
244
+}

+ 13
- 2
resources/data/lang/id.json Bestand weergeven

@@ -229,5 +229,16 @@
229 229
     "Website": "Situs Web",
230 230
     "Resources": "Sumber daya",
231 231
     "Finance": "Keuangan",
232
-    "Personalization": "Personalisasi"
233
-}
232
+    "Personalization": "Personalisasi",
233
+    "Connected": "Terhubung",
234
+    "Manual": "Manual",
235
+    "Subtype": "Subtipe",
236
+    "Connected Accounts": "Akun Terhubung",
237
+    "Brokerage": "Pialang",
238
+    "Investment": "Investasi",
239
+    "Routing Number": "Nomor Routing",
240
+    "Credit": "Kredit",
241
+    "Loan": "Pinjaman",
242
+    "Depository": "Depository",
243
+    "Accounts": "Akun"
244
+}

+ 13
- 2
resources/data/lang/it.json Bestand weergeven

@@ -229,5 +229,16 @@
229 229
     "Website": "Sito Web",
230 230
     "Finance": "Finanza",
231 231
     "Personalization": "Personalizzazione",
232
-    "Management": "Direzione"
233
-}
232
+    "Management": "Direzione",
233
+    "Connected Accounts": "Account connessi",
234
+    "Subtype": "Sottotipo",
235
+    "Connected": "Connesso",
236
+    "Depository": "Depositario",
237
+    "Loan": "Prestito",
238
+    "Investment": "Investimento",
239
+    "Brokerage": "Intermediazione",
240
+    "Routing Number": "Numero di routing",
241
+    "Manual": "Manuale",
242
+    "Accounts": "Account",
243
+    "Credit": "Credito"
244
+}

+ 0
- 0
resources/data/lang/nl.json Bestand weergeven


Some files were not shown because too many files changed in this diff

Laden…
Annuleren
Opslaan