Browse Source

Merge pull request #85 from andrewdwallo/development-3.x

Add Bills and Invoices Management with Full Accounting Integration
3.x
Andrew Wallo 10 months ago
parent
commit
f50cf871ef
No account linked to committer's email address
100 changed files with 5709 additions and 758 deletions
  1. 46
    0
      app/Casts/DocumentMoneyCast.php
  2. 36
    0
      app/Collections/Accounting/InvoiceCollection.php
  3. 11
    0
      app/Concerns/RedirectToListPage.php
  4. 31
    0
      app/Console/Commands/UpdateOverdueInvoices.php
  5. 2
    0
      app/Contracts/HasSummaryReport.php
  6. 1
    1
      app/DTO/AccountTransactionDTO.php
  7. 29
    0
      app/Enums/Accounting/AdjustmentCategory.php
  8. 2
    4
      app/Enums/Accounting/AdjustmentComputation.php
  9. 2
    4
      app/Enums/Accounting/AdjustmentScope.php
  10. 15
    7
      app/Enums/Accounting/AdjustmentType.php
  11. 38
    0
      app/Enums/Accounting/BillStatus.php
  12. 48
    0
      app/Enums/Accounting/InvoiceStatus.php
  13. 27
    0
      app/Enums/Accounting/PaymentMethod.php
  14. 17
    0
      app/Enums/Common/AddressType.php
  15. 19
    0
      app/Enums/Common/ContractorType.php
  16. 25
    0
      app/Enums/Common/OfferingType.php
  17. 31
    0
      app/Enums/Common/VendorType.php
  18. 5
    1
      app/Enums/Concerns/ParsesEnum.php
  19. 0
    16
      app/Enums/Setting/DiscountScope.php
  20. 0
    19
      app/Enums/Setting/TaxComputation.php
  21. 0
    39
      app/Enums/Setting/TaxType.php
  22. 2
    18
      app/Filament/Company/Clusters/Settings/Pages/Appearance.php
  23. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php
  24. 2
    0
      app/Filament/Company/Clusters/Settings/Pages/Localization.php
  25. 130
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php
  26. 11
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/CreateAdjustment.php
  27. 18
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/EditAdjustment.php
  28. 4
    4
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/ListAdjustments.php
  29. 0
    189
      app/Filament/Company/Clusters/Settings/Resources/DiscountResource.php
  30. 0
    16
      app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/CreateDiscount.php
  31. 0
    24
      app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/EditDiscount.php
  32. 0
    158
      app/Filament/Company/Clusters/Settings/Resources/TaxResource.php
  33. 0
    16
      app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/CreateTax.php
  34. 0
    24
      app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/EditTax.php
  35. 2
    0
      app/Filament/Company/Pages/Accounting/AccountChart.php
  36. 22
    15
      app/Filament/Company/Pages/Accounting/Transactions.php
  37. 24
    0
      app/Filament/Company/Pages/Concerns/HasReportTabs.php
  38. 22
    0
      app/Filament/Company/Pages/CreateCompany.php
  39. 28
    8
      app/Filament/Company/Pages/Reports.php
  40. 0
    4
      app/Filament/Company/Pages/Reports/AccountBalances.php
  41. 43
    7
      app/Filament/Company/Pages/Reports/AccountTransactions.php
  42. 5
    8
      app/Filament/Company/Pages/Reports/BalanceSheet.php
  43. 26
    0
      app/Filament/Company/Pages/Reports/BaseReportPage.php
  44. 5
    10
      app/Filament/Company/Pages/Reports/CashFlowStatement.php
  45. 5
    10
      app/Filament/Company/Pages/Reports/IncomeStatement.php
  46. 0
    4
      app/Filament/Company/Pages/Reports/TrialBalance.php
  47. 6
    0
      app/Filament/Company/Resources/Banking/AccountResource/Pages/ListAccounts.php
  48. 195
    0
      app/Filament/Company/Resources/Common/OfferingResource.php
  49. 27
    0
      app/Filament/Company/Resources/Common/OfferingResource/Pages/CreateOffering.php
  50. 45
    0
      app/Filament/Company/Resources/Common/OfferingResource/Pages/EditOffering.php
  51. 5
    5
      app/Filament/Company/Resources/Common/OfferingResource/Pages/ListOfferings.php
  52. 579
    0
      app/Filament/Company/Resources/Purchases/BillResource.php
  53. 20
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php
  54. 28
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php
  55. 61
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/ListBills.php
  56. 78
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/ViewBill.php
  57. 182
    0
      app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php
  58. 69
    0
      app/Filament/Company/Resources/Purchases/BillResource/Widgets/BillOverview.php
  59. 220
    0
      app/Filament/Company/Resources/Purchases/VendorResource.php
  60. 20
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/CreateVendor.php
  61. 28
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/EditVendor.php
  62. 25
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/ListVendors.php
  63. 294
    0
      app/Filament/Company/Resources/Sales/ClientResource.php
  64. 20
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php
  65. 28
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/EditClient.php
  66. 25
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/ListClients.php
  67. 706
    0
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  68. 20
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php
  69. 28
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php
  70. 62
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php
  71. 88
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php
  72. 187
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php
  73. 69
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Widgets/InvoiceOverview.php
  74. 13
    0
      app/Filament/Forms/Components/CustomSection.php
  75. 165
    0
      app/Filament/Forms/Components/LineItemRepeater.php
  76. 10
    0
      app/Filament/Forms/Components/PhoneBuilder.php
  77. 31
    5
      app/Filament/Tables/Actions/ReplicateBulkAction.php
  78. 172
    0
      app/Filament/Tables/Filters/DateRangeFilter.php
  79. 12
    0
      app/Filament/Widgets/EnhancedStatsOverviewWidget.php
  80. 47
    0
      app/Filament/Widgets/EnhancedStatsOverviewWidget/EnhancedStat.php
  81. 30
    0
      app/Jobs/ProcessOverdueInvoices.php
  82. 13
    35
      app/Listeners/ConfigureCompanyDefault.php
  83. 0
    4
      app/Listeners/SyncAssociatedModels.php
  84. 0
    29
      app/Listeners/SyncWithCompanyDefaults.php
  85. 18
    3
      app/Models/Accounting/Account.php
  86. 98
    0
      app/Models/Accounting/Adjustment.php
  87. 311
    0
      app/Models/Accounting/Bill.php
  88. 132
    0
      app/Models/Accounting/DocumentLineItem.php
  89. 384
    0
      app/Models/Accounting/Invoice.php
  90. 10
    0
      app/Models/Accounting/Transaction.php
  91. 5
    4
      app/Models/Banking/BankAccount.php
  92. 44
    0
      app/Models/Common/Address.php
  93. 79
    0
      app/Models/Common/Client.php
  94. 72
    22
      app/Models/Common/Contact.php
  95. 114
    0
      app/Models/Common/Offering.php
  96. 66
    0
      app/Models/Common/Vendor.php
  97. 33
    8
      app/Models/Company.php
  98. 0
    6
      app/Models/Setting/Appearance.php
  99. 0
    30
      app/Models/Setting/CompanyDefault.php
  100. 0
    0
      app/Models/Setting/Discount.php

+ 46
- 0
app/Casts/DocumentMoneyCast.php View File

@@ -0,0 +1,46 @@
1
+<?php
2
+
3
+namespace App\Casts;
4
+
5
+use App\Utilities\Currency\CurrencyAccessor;
6
+use App\Utilities\Currency\CurrencyConverter;
7
+use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8
+use Illuminate\Database\Eloquent\Model;
9
+use UnexpectedValueException;
10
+
11
+class DocumentMoneyCast implements CastsAttributes
12
+{
13
+    /**
14
+     * Cast the given value.
15
+     *
16
+     * @param  array<string, mixed>  $attributes
17
+     */
18
+    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
19
+    {
20
+        $currency_code = $attributes['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
21
+
22
+        if ($value !== null) {
23
+            return CurrencyConverter::convertCentsToFloat($value, $currency_code);
24
+        }
25
+
26
+        return 0.0;
27
+    }
28
+
29
+    /**
30
+     * Prepare the given value for storage.
31
+     *
32
+     * @param  array<string, mixed>  $attributes
33
+     */
34
+    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
35
+    {
36
+        $currency_code = $attributes['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
37
+
38
+        if (is_numeric($value)) {
39
+            $value = (string) $value;
40
+        } elseif (! is_string($value)) {
41
+            throw new UnexpectedValueException('Expected string or numeric value for money cast');
42
+        }
43
+
44
+        return CurrencyConverter::prepareForAccessor($value, $currency_code);
45
+    }
46
+}

+ 36
- 0
app/Collections/Accounting/InvoiceCollection.php View File

@@ -0,0 +1,36 @@
1
+<?php
2
+
3
+namespace App\Collections\Accounting;
4
+
5
+use App\Models\Accounting\Invoice;
6
+use App\Utilities\Currency\CurrencyAccessor;
7
+use App\Utilities\Currency\CurrencyConverter;
8
+use Illuminate\Database\Eloquent\Collection;
9
+
10
+class InvoiceCollection extends Collection
11
+{
12
+    public function sumMoneyInCents(string $column): int
13
+    {
14
+        return $this->reduce(static function ($carry, Invoice $invoice) use ($column) {
15
+            return $carry + $invoice->getRawOriginal($column);
16
+        }, 0);
17
+    }
18
+
19
+    public function sumMoneyFormattedSimple(string $column, ?string $currency = null): string
20
+    {
21
+        $currency ??= CurrencyAccessor::getDefaultCurrency();
22
+
23
+        $totalCents = $this->sumMoneyInCents($column);
24
+
25
+        return CurrencyConverter::convertCentsToFormatSimple($totalCents, $currency);
26
+    }
27
+
28
+    public function sumMoneyFormatted(string $column, ?string $currency = null): string
29
+    {
30
+        $currency ??= CurrencyAccessor::getDefaultCurrency();
31
+
32
+        $totalCents = $this->sumMoneyInCents($column);
33
+
34
+        return CurrencyConverter::formatCentsToMoney($totalCents, $currency);
35
+    }
36
+}

+ 11
- 0
app/Concerns/RedirectToListPage.php View File

@@ -0,0 +1,11 @@
1
+<?php
2
+
3
+namespace App\Concerns;
4
+
5
+trait RedirectToListPage
6
+{
7
+    protected function getRedirectUrl(): string
8
+    {
9
+        return $this->getResource()::getUrl('index');
10
+    }
11
+}

+ 31
- 0
app/Console/Commands/UpdateOverdueInvoices.php View File

@@ -0,0 +1,31 @@
1
+<?php
2
+
3
+namespace App\Console\Commands;
4
+
5
+use App\Jobs\ProcessOverdueInvoices;
6
+use Illuminate\Console\Command;
7
+
8
+class UpdateOverdueInvoices extends Command
9
+{
10
+    /**
11
+     * The name and signature of the console command.
12
+     *
13
+     * @var string
14
+     */
15
+    protected $signature = 'app:update-overdue-invoices';
16
+
17
+    /**
18
+     * The console command description.
19
+     *
20
+     * @var string
21
+     */
22
+    protected $description = 'Check and mark overdue invoices as overdue';
23
+
24
+    /**
25
+     * Execute the console command.
26
+     */
27
+    public function handle(): void
28
+    {
29
+        ProcessOverdueInvoices::dispatch();
30
+    }
31
+}

+ 2
- 0
app/Contracts/HasSummaryReport.php View File

@@ -20,4 +20,6 @@ interface HasSummaryReport
20 20
     public function getSummaryCategories(): array;
21 21
 
22 22
     public function getSummaryOverallTotals(): array;
23
+
24
+    public function getSummaryPdfView(): string;
23 25
 }

+ 1
- 1
app/DTO/AccountTransactionDTO.php View File

@@ -14,6 +14,6 @@ class AccountTransactionDTO
14 14
         public string $credit,
15 15
         public string $balance,
16 16
         public ?TransactionType $type,
17
-        public ?string $tableAction,
17
+        public ?array $tableAction,
18 18
     ) {}
19 19
 }

+ 29
- 0
app/Enums/Accounting/AdjustmentCategory.php View File

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum AdjustmentCategory: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Tax = 'tax';
13
+    case Discount = 'discount';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+
20
+    public function isTax(): bool
21
+    {
22
+        return $this === self::Tax;
23
+    }
24
+
25
+    public function isDiscount(): bool
26
+    {
27
+        return $this === self::Discount;
28
+    }
29
+}

app/Enums/Setting/DiscountComputation.php → app/Enums/Accounting/AdjustmentComputation.php View File

@@ -1,16 +1,14 @@
1 1
 <?php
2 2
 
3
-namespace App\Enums\Setting;
3
+namespace App\Enums\Accounting;
4 4
 
5 5
 use Filament\Support\Contracts\HasLabel;
6 6
 
7
-enum DiscountComputation: string implements HasLabel
7
+enum AdjustmentComputation: string implements HasLabel
8 8
 {
9 9
     case Percentage = 'percentage';
10 10
     case Fixed = 'fixed';
11 11
 
12
-    public const DEFAULT = self::Percentage->value;
13
-
14 12
     public function getLabel(): ?string
15 13
     {
16 14
         return translate($this->name);

app/Enums/Setting/TaxScope.php → app/Enums/Accounting/AdjustmentScope.php View File

@@ -1,10 +1,8 @@
1 1
 <?php
2 2
 
3
-namespace App\Enums\Setting;
3
+namespace App\Enums\Accounting;
4 4
 
5
-use Filament\Support\Contracts\HasLabel;
6
-
7
-enum TaxScope: string implements HasLabel
5
+enum AdjustmentScope: string
8 6
 {
9 7
     case Product = 'product';
10 8
     case Service = 'service';

app/Enums/Setting/DiscountType.php → app/Enums/Accounting/AdjustmentType.php View File

@@ -1,18 +1,18 @@
1 1
 <?php
2 2
 
3
-namespace App\Enums\Setting;
3
+namespace App\Enums\Accounting;
4 4
 
5
+use App\Enums\Concerns\ParsesEnum;
5 6
 use Filament\Support\Contracts\HasColor;
6 7
 use Filament\Support\Contracts\HasIcon;
7 8
 use Filament\Support\Contracts\HasLabel;
8 9
 
9
-enum DiscountType: string implements HasColor, HasIcon, HasLabel
10
+enum AdjustmentType: string implements HasColor, HasIcon, HasLabel
10 11
 {
12
+    use ParsesEnum;
13
+
11 14
     case Sales = 'sales';
12 15
     case Purchase = 'purchase';
13
-    case None = 'none';
14
-
15
-    public const DEFAULT = self::Sales->value;
16 16
 
17 17
     public function getLabel(): ?string
18 18
     {
@@ -24,7 +24,6 @@ enum DiscountType: string implements HasColor, HasIcon, HasLabel
24 24
         return match ($this) {
25 25
             self::Sales => 'success',
26 26
             self::Purchase => 'warning',
27
-            self::None => 'gray',
28 27
         };
29 28
     }
30 29
 
@@ -33,7 +32,16 @@ enum DiscountType: string implements HasColor, HasIcon, HasLabel
33 32
         return match ($this) {
34 33
             self::Sales => 'heroicon-o-currency-dollar',
35 34
             self::Purchase => 'heroicon-o-shopping-bag',
36
-            self::None => 'heroicon-o-x-circle',
37 35
         };
38 36
     }
37
+
38
+    public function isSales(): bool
39
+    {
40
+        return $this === self::Sales;
41
+    }
42
+
43
+    public function isPurchase(): bool
44
+    {
45
+        return $this === self::Purchase;
46
+    }
39 47
 }

+ 38
- 0
app/Enums/Accounting/BillStatus.php View File

@@ -0,0 +1,38 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum BillStatus: string implements HasColor, HasLabel
9
+{
10
+    case Overdue = 'overdue';
11
+    case Partial = 'partial';
12
+    case Paid = 'paid';
13
+    case Unpaid = 'unpaid';
14
+    case Void = 'void';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return $this->name;
19
+    }
20
+
21
+    public function getColor(): string | array | null
22
+    {
23
+        return match ($this) {
24
+            self::Overdue => 'danger',
25
+            self::Partial, self::Unpaid => 'warning',
26
+            self::Paid => 'success',
27
+            self::Void => 'gray',
28
+        };
29
+    }
30
+
31
+    public static function canBeOverdue(): array
32
+    {
33
+        return [
34
+            self::Partial,
35
+            self::Unpaid,
36
+        ];
37
+    }
38
+}

+ 48
- 0
app/Enums/Accounting/InvoiceStatus.php View File

@@ -0,0 +1,48 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum InvoiceStatus: string implements HasColor, HasLabel
9
+{
10
+    case Draft = 'draft';
11
+    case Unsent = 'unsent';
12
+    case Sent = 'sent';
13
+
14
+    case Partial = 'partial';
15
+
16
+    case Paid = 'paid';
17
+
18
+    case Overdue = 'overdue';
19
+
20
+    case Overpaid = 'overpaid';
21
+
22
+    case Void = 'void';
23
+
24
+    public function getLabel(): ?string
25
+    {
26
+        return $this->name;
27
+    }
28
+
29
+    public function getColor(): string | array | null
30
+    {
31
+        return match ($this) {
32
+            self::Draft, self::Unsent, self::Void => 'gray',
33
+            self::Sent => 'primary',
34
+            self::Partial => 'warning',
35
+            self::Paid, self::Overpaid => 'success',
36
+            self::Overdue => 'danger',
37
+        };
38
+    }
39
+
40
+    public static function canBeOverdue(): array
41
+    {
42
+        return [
43
+            self::Partial,
44
+            self::Sent,
45
+            self::Unsent,
46
+        ];
47
+    }
48
+}

+ 27
- 0
app/Enums/Accounting/PaymentMethod.php View File

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum PaymentMethod: string implements HasLabel
8
+{
9
+    case BankPayment = 'bank_payment';
10
+    case Cash = 'cash';
11
+    case Check = 'check';
12
+    case CreditCard = 'credit_card';
13
+    case PayPal = 'paypal';
14
+    case Other = 'other';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return match ($this) {
19
+            self::BankPayment => 'Bank Payment',
20
+            self::Cash => 'Cash',
21
+            self::Check => 'Check',
22
+            self::CreditCard => 'Credit Card',
23
+            self::PayPal => 'PayPal',
24
+            self::Other => 'Other',
25
+        };
26
+    }
27
+}

+ 17
- 0
app/Enums/Common/AddressType.php View File

@@ -0,0 +1,17 @@
1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum AddressType: string implements HasLabel
8
+{
9
+    case General = 'general';
10
+    case Billing = 'billing';
11
+    case Shipping = 'shipping';
12
+
13
+    public function getLabel(): string
14
+    {
15
+        return $this->name;
16
+    }
17
+}

+ 19
- 0
app/Enums/Common/ContractorType.php View File

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum ContractorType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Individual = 'individual';
13
+    case Business = 'business';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+}

+ 25
- 0
app/Enums/Common/OfferingType.php View File

@@ -0,0 +1,25 @@
1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+use JaOcero\RadioDeck\Contracts\HasIcons;
7
+
8
+enum OfferingType: string implements HasIcons, HasLabel
9
+{
10
+    case Product = 'product';
11
+    case Service = 'service';
12
+
13
+    public function getLabel(): string
14
+    {
15
+        return $this->name;
16
+    }
17
+
18
+    public function getIcons(): ?string
19
+    {
20
+        return match ($this) {
21
+            self::Product => 'heroicon-o-cube-transparent',
22
+            self::Service => 'heroicon-o-briefcase',
23
+        };
24
+    }
25
+}

+ 31
- 0
app/Enums/Common/VendorType.php View File

@@ -0,0 +1,31 @@
1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasDescription;
7
+use Filament\Support\Contracts\HasLabel;
8
+
9
+enum VendorType: string implements HasDescription, HasLabel
10
+{
11
+    use ParsesEnum;
12
+
13
+    case Regular = 'regular';
14
+    case Contractor = 'contractor';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return match ($this) {
19
+            self::Regular => 'Regular',
20
+            self::Contractor => '1099-NEC Contractor',
21
+        };
22
+    }
23
+
24
+    public function getDescription(): ?string
25
+    {
26
+        return match ($this) {
27
+            self::Regular => 'Vendors who supply goods or services to your business, such as office supplies, utilities, or equipment.',
28
+            self::Contractor => 'Independent contractors providing services to your business, typically requiring 1099-NEC reporting for tax purposes.',
29
+        };
30
+    }
31
+}

+ 5
- 1
app/Enums/Concerns/ParsesEnum.php View File

@@ -4,8 +4,12 @@ namespace App\Enums\Concerns;
4 4
 
5 5
 trait ParsesEnum
6 6
 {
7
-    public static function parse(string | self $value): self
7
+    public static function parse(string | self | null $value): ?self
8 8
     {
9
+        if ($value === null) {
10
+            return null;
11
+        }
12
+
9 13
         if ($value instanceof self) {
10 14
             return $value;
11 15
         }

+ 0
- 16
app/Enums/Setting/DiscountScope.php View File

@@ -1,16 +0,0 @@
1
-<?php
2
-
3
-namespace App\Enums\Setting;
4
-
5
-use Filament\Support\Contracts\HasLabel;
6
-
7
-enum DiscountScope: string implements HasLabel
8
-{
9
-    case Product = 'product';
10
-    case Service = 'service';
11
-
12
-    public function getLabel(): ?string
13
-    {
14
-        return translate($this->name);
15
-    }
16
-}

+ 0
- 19
app/Enums/Setting/TaxComputation.php View File

@@ -1,19 +0,0 @@
1
-<?php
2
-
3
-namespace App\Enums\Setting;
4
-
5
-use Filament\Support\Contracts\HasLabel;
6
-
7
-enum TaxComputation: string implements HasLabel
8
-{
9
-    case Fixed = 'fixed';
10
-    case Percentage = 'percentage';
11
-    case Compound = 'compound';
12
-
13
-    public const DEFAULT = self::Percentage->value;
14
-
15
-    public function getLabel(): ?string
16
-    {
17
-        return translate($this->name);
18
-    }
19
-}

+ 0
- 39
app/Enums/Setting/TaxType.php View File

@@ -1,39 +0,0 @@
1
-<?php
2
-
3
-namespace App\Enums\Setting;
4
-
5
-use Filament\Support\Contracts\HasColor;
6
-use Filament\Support\Contracts\HasIcon;
7
-use Filament\Support\Contracts\HasLabel;
8
-
9
-enum TaxType: string implements HasColor, HasIcon, HasLabel
10
-{
11
-    case Sales = 'sales';
12
-    case Purchase = 'purchase';
13
-    case None = 'none';
14
-
15
-    public const DEFAULT = self::Sales->value;
16
-
17
-    public function getLabel(): ?string
18
-    {
19
-        return translate($this->name);
20
-    }
21
-
22
-    public function getColor(): string | array | null
23
-    {
24
-        return match ($this) {
25
-            self::Sales => 'success',
26
-            self::Purchase => 'warning',
27
-            self::None => 'gray',
28
-        };
29
-    }
30
-
31
-    public function getIcon(): ?string
32
-    {
33
-        return match ($this) {
34
-            self::Sales => 'heroicon-o-currency-dollar',
35
-            self::Purchase => 'heroicon-o-shopping-bag',
36
-            self::None => 'heroicon-o-x-circle',
37
-        };
38
-    }
39
-}

+ 2
- 18
app/Filament/Company/Clusters/Settings/Pages/Appearance.php View File

@@ -4,10 +4,9 @@ namespace App\Filament\Company\Clusters\Settings\Pages;
4 4
 
5 5
 use App\Enums\Setting\Font;
6 6
 use App\Enums\Setting\PrimaryColor;
7
-use App\Enums\Setting\RecordsPerPage;
8
-use App\Enums\Setting\TableSortDirection;
9 7
 use App\Filament\Company\Clusters\Settings;
10 8
 use App\Models\Setting\Appearance as AppearanceModel;
9
+use App\Services\CompanySettingsService;
11 10
 use Filament\Actions\Action;
12 11
 use Filament\Actions\ActionGroup;
13 12
 use Filament\Forms\Components\Component;
@@ -103,7 +102,6 @@ class Appearance extends Page
103 102
         return $form
104 103
             ->schema([
105 104
                 $this->getGeneralSection(),
106
-                $this->getDataPresentationSection(),
107 105
             ])
108 106
             ->model($this->record)
109 107
             ->statePath('data')
@@ -141,21 +139,6 @@ class Appearance extends Page
141 139
             ])->columns();
142 140
     }
143 141
 
144
-    protected function getDataPresentationSection(): Component
145
-    {
146
-        return Section::make('Data Presentation')
147
-            ->schema([
148
-                Select::make('table_sort_direction')
149
-                    ->softRequired()
150
-                    ->localizeLabel()
151
-                    ->options(TableSortDirection::class),
152
-                Select::make('records_per_page')
153
-                    ->softRequired()
154
-                    ->localizeLabel()
155
-                    ->options(RecordsPerPage::class),
156
-            ])->columns();
157
-    }
158
-
159 142
     protected function handleRecordUpdate(AppearanceModel $record, array $data): AppearanceModel
160 143
     {
161 144
         $record->fill($data);
@@ -166,6 +149,7 @@ class Appearance extends Page
166 149
         ];
167 150
 
168 151
         if ($record->isDirty($keysToWatch)) {
152
+            CompanySettingsService::invalidateSettings($record->company_id);
169 153
             $this->dispatch('appearanceUpdated');
170 154
         }
171 155
 

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php View File

@@ -108,7 +108,7 @@ class CompanyDefault extends Page
108 108
         return $form
109 109
             ->schema([
110 110
                 $this->getGeneralSection(),
111
-                $this->getModifiersSection(),
111
+                // $this->getModifiersSection(),
112 112
             ])
113 113
             ->model($this->record)
114 114
             ->statePath('data')

+ 2
- 0
app/Filament/Company/Clusters/Settings/Pages/Localization.php View File

@@ -9,6 +9,7 @@ use App\Enums\Setting\WeekStart;
9 9
 use App\Filament\Company\Clusters\Settings;
10 10
 use App\Models\Setting\CompanyProfile as CompanyProfileModel;
11 11
 use App\Models\Setting\Localization as LocalizationModel;
12
+use App\Services\CompanySettingsService;
12 13
 use App\Utilities\Localization\Timezone;
13 14
 use Filament\Actions\Action;
14 15
 use Filament\Actions\ActionGroup;
@@ -214,6 +215,7 @@ class Localization extends Page
214 215
         ];
215 216
 
216 217
         if ($record->isDirty($keysToWatch)) {
218
+            CompanySettingsService::invalidateSettings($record->company_id);
217 219
             $this->dispatch('localizationUpdated');
218 220
         }
219 221
 

+ 130
- 0
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php View File

@@ -0,0 +1,130 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Clusters\Settings\Resources;
4
+
5
+use App\Enums\Accounting\AdjustmentCategory;
6
+use App\Enums\Accounting\AdjustmentComputation;
7
+use App\Enums\Accounting\AdjustmentScope;
8
+use App\Enums\Accounting\AdjustmentType;
9
+use App\Filament\Company\Clusters\Settings;
10
+use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
11
+use App\Models\Accounting\Adjustment;
12
+use Filament\Forms;
13
+use Filament\Forms\Form;
14
+use Filament\Resources\Resource;
15
+use Filament\Tables;
16
+use Filament\Tables\Table;
17
+use Wallo\FilamentSelectify\Components\ToggleButton;
18
+
19
+class AdjustmentResource extends Resource
20
+{
21
+    protected static ?string $model = Adjustment::class;
22
+
23
+    protected static ?string $cluster = Settings::class;
24
+
25
+    public static function form(Form $form): Form
26
+    {
27
+        return $form
28
+            ->schema([
29
+                Forms\Components\Section::make('General')
30
+                    ->schema([
31
+                        Forms\Components\TextInput::make('name')
32
+                            ->autofocus()
33
+                            ->required()
34
+                            ->maxLength(255),
35
+                        Forms\Components\Textarea::make('description')
36
+                            ->label('Description')
37
+                            ->autosize(),
38
+                    ]),
39
+                Forms\Components\Section::make('Configuration')
40
+                    ->schema([
41
+                        Forms\Components\Select::make('category')
42
+                            ->localizeLabel()
43
+                            ->options(AdjustmentCategory::class)
44
+                            ->default(AdjustmentCategory::Tax)
45
+                            ->live()
46
+                            ->required(),
47
+                        Forms\Components\Select::make('type')
48
+                            ->localizeLabel()
49
+                            ->options(AdjustmentType::class)
50
+                            ->default(AdjustmentType::Sales)
51
+                            ->live()
52
+                            ->required(),
53
+                        ToggleButton::make('recoverable')
54
+                            ->label('Recoverable')
55
+                            ->default(false)
56
+                            ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category')) === AdjustmentCategory::Tax && AdjustmentType::parse($get('type')) === AdjustmentType::Purchase),
57
+                    ])
58
+                    ->columns()
59
+                    ->visibleOn('create'),
60
+                Forms\Components\Section::make('Adjustment Details')
61
+                    ->schema([
62
+                        Forms\Components\Select::make('computation')
63
+                            ->localizeLabel()
64
+                            ->options(AdjustmentComputation::class)
65
+                            ->default(AdjustmentComputation::Percentage)
66
+                            ->live()
67
+                            ->required(),
68
+                        Forms\Components\TextInput::make('rate')
69
+                            ->localizeLabel()
70
+                            ->rate(static fn (Forms\Get $get) => $get('computation'))
71
+                            ->required(),
72
+                        Forms\Components\Select::make('scope')
73
+                            ->localizeLabel()
74
+                            ->options(AdjustmentScope::class),
75
+                    ])
76
+                    ->columns(),
77
+                Forms\Components\Section::make('Dates')
78
+                    ->schema([
79
+                        Forms\Components\DateTimePicker::make('start_date'),
80
+                        Forms\Components\DateTimePicker::make('end_date'),
81
+                    ])
82
+                    ->columns()
83
+                    ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category')) === AdjustmentCategory::Discount),
84
+            ]);
85
+    }
86
+
87
+    public static function table(Table $table): Table
88
+    {
89
+        return $table
90
+            ->columns([
91
+                Tables\Columns\TextColumn::make('name')
92
+                    ->label('Name')
93
+                    ->sortable(),
94
+                Tables\Columns\TextColumn::make('category')
95
+                    ->searchable(),
96
+                Tables\Columns\TextColumn::make('type')
97
+                    ->searchable(),
98
+                Tables\Columns\TextColumn::make('rate')
99
+                    ->localizeLabel()
100
+                    ->rate(static fn (Adjustment $record) => $record->computation->value)
101
+                    ->searchable()
102
+                    ->sortable(),
103
+            ])
104
+            ->filters([
105
+                //
106
+            ])
107
+            ->actions([
108
+                Tables\Actions\EditAction::make(),
109
+            ])
110
+            ->bulkActions([
111
+                //
112
+            ]);
113
+    }
114
+
115
+    public static function getRelations(): array
116
+    {
117
+        return [
118
+            //
119
+        ];
120
+    }
121
+
122
+    public static function getPages(): array
123
+    {
124
+        return [
125
+            'index' => Pages\ListAdjustments::route('/'),
126
+            'create' => Pages\CreateAdjustment::route('/create'),
127
+            'edit' => Pages\EditAdjustment::route('/{record}/edit'),
128
+        ];
129
+    }
130
+}

+ 11
- 0
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/CreateAdjustment.php View File

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

+ 18
- 0
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/EditAdjustment.php View File

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

app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/ListDiscounts.php → app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/ListAdjustments.php View File

@@ -1,15 +1,15 @@
1 1
 <?php
2 2
 
3
-namespace App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
3
+namespace App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
4 4
 
5
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource;
5
+use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource;
6 6
 use Filament\Actions;
7 7
 use Filament\Resources\Pages\ListRecords;
8 8
 use Filament\Support\Enums\MaxWidth;
9 9
 
10
-class ListDiscounts extends ListRecords
10
+class ListAdjustments extends ListRecords
11 11
 {
12
-    protected static string $resource = DiscountResource::class;
12
+    protected static string $resource = AdjustmentResource::class;
13 13
 
14 14
     protected function getHeaderActions(): array
15 15
     {

+ 0
- 189
app/Filament/Company/Clusters/Settings/Resources/DiscountResource.php View File

@@ -1,189 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources;
4
-
5
-use App\Concerns\NotifiesOnDelete;
6
-use App\Enums\Setting\DateFormat;
7
-use App\Enums\Setting\DiscountComputation;
8
-use App\Enums\Setting\DiscountScope;
9
-use App\Enums\Setting\DiscountType;
10
-use App\Enums\Setting\TimeFormat;
11
-use App\Filament\Company\Clusters\Settings;
12
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
13
-use App\Models\Setting\Discount;
14
-use App\Models\Setting\Localization;
15
-use Closure;
16
-use Filament\Forms;
17
-use Filament\Forms\Form;
18
-use Filament\Resources\Resource;
19
-use Filament\Support\Enums\FontWeight;
20
-use Filament\Tables;
21
-use Filament\Tables\Table;
22
-use Wallo\FilamentSelectify\Components\ToggleButton;
23
-
24
-class DiscountResource extends Resource
25
-{
26
-    use NotifiesOnDelete;
27
-
28
-    protected static ?string $model = Discount::class;
29
-
30
-    protected static ?string $modelLabel = 'Discount';
31
-
32
-    protected static ?string $cluster = Settings::class;
33
-
34
-    public static function getModelLabel(): string
35
-    {
36
-        $modelLabel = static::$modelLabel;
37
-
38
-        return translate($modelLabel);
39
-    }
40
-
41
-    public static function form(Form $form): Form
42
-    {
43
-        return $form
44
-            ->schema([
45
-                Forms\Components\Section::make('General')
46
-                    ->schema([
47
-                        Forms\Components\TextInput::make('name')
48
-                            ->autofocus()
49
-                            ->required()
50
-                            ->localizeLabel()
51
-                            ->maxLength(255)
52
-                            ->rule(static function (Forms\Get $get, Forms\Components\Component $component): Closure {
53
-                                return static function (string $attribute, $value, Closure $fail) use ($component, $get) {
54
-                                    $existingDiscount = Discount::where('name', $value)
55
-                                        ->whereKeyNot($component->getRecord()?->getKey())
56
-                                        ->where('type', $get('type'))
57
-                                        ->first();
58
-
59
-                                    if ($existingDiscount) {
60
-                                        $message = translate('The :Type :record ":name" already exists.', [
61
-                                            'Type' => $existingDiscount->type->getLabel(),
62
-                                            'record' => strtolower(static::getModelLabel()),
63
-                                            'name' => $value,
64
-                                        ]);
65
-
66
-                                        $fail($message);
67
-                                    }
68
-                                };
69
-                            }),
70
-                        Forms\Components\TextInput::make('description')
71
-                            ->localizeLabel(),
72
-                        Forms\Components\Select::make('computation')
73
-                            ->localizeLabel()
74
-                            ->options(DiscountComputation::class)
75
-                            ->default(DiscountComputation::Percentage)
76
-                            ->live()
77
-                            ->required(),
78
-                        Forms\Components\TextInput::make('rate')
79
-                            ->localizeLabel()
80
-                            ->rate(static fn (Forms\Get $get) => $get('computation'))
81
-                            ->required(),
82
-                        Forms\Components\Select::make('type')
83
-                            ->localizeLabel()
84
-                            ->options(DiscountType::class)
85
-                            ->default(DiscountType::Sales)
86
-                            ->required(),
87
-                        Forms\Components\Select::make('scope')
88
-                            ->localizeLabel()
89
-                            ->options(DiscountScope::class)
90
-                            ->nullable(),
91
-                        Forms\Components\DateTimePicker::make('start_date')
92
-                            ->localizeLabel()
93
-                            ->beforeOrEqual('end_date')
94
-                            ->seconds(false)
95
-                            ->disabled(static fn (string $operation, ?Discount $record = null) => $operation === 'edit' && $record?->start_date?->isPast() ?? false)
96
-                            ->helperText(static fn (Forms\Components\DateTimePicker $component) => $component->isDisabled() ? 'Start date cannot be changed after the discount has begun.' : null),
97
-                        Forms\Components\DateTimePicker::make('end_date')
98
-                            ->localizeLabel()
99
-                            ->afterOrEqual('start_date')
100
-                            ->seconds(false),
101
-                        ToggleButton::make('enabled')
102
-                            ->localizeLabel('Default')
103
-                            ->onLabel(Discount::enabledLabel())
104
-                            ->offLabel(Discount::disabledLabel()),
105
-                    ])->columns(),
106
-            ]);
107
-    }
108
-
109
-    public static function table(Table $table): Table
110
-    {
111
-        return $table
112
-            ->columns([
113
-                Tables\Columns\TextColumn::make('name')
114
-                    ->localizeLabel()
115
-                    ->weight(FontWeight::Medium)
116
-                    ->icon(static fn (Discount $record) => $record->isEnabled() ? 'heroicon-o-lock-closed' : null)
117
-                    ->tooltip(static function (Discount $record) {
118
-                        if ($record->isDisabled()) {
119
-                            return null;
120
-                        }
121
-
122
-                        return translate('Default :Type :Record', [
123
-                            'Type' => $record->type->getLabel(),
124
-                            'Record' => static::getModelLabel(),
125
-                        ]);
126
-                    })
127
-                    ->iconPosition('after')
128
-                    ->searchable()
129
-                    ->sortable(),
130
-                Tables\Columns\TextColumn::make('rate')
131
-                    ->localizeLabel()
132
-                    ->rate(static fn (Discount $record) => $record->computation->value)
133
-                    ->searchable()
134
-                    ->sortable(),
135
-                Tables\Columns\TextColumn::make('type')
136
-                    ->localizeLabel()
137
-                    ->badge()
138
-                    ->searchable()
139
-                    ->sortable(),
140
-                Tables\Columns\TextColumn::make('start_date')
141
-                    ->localizeLabel()
142
-                    ->formatStateUsing(static function (Discount $record) {
143
-                        $dateFormat = Localization::firstOrFail()->date_format->value ?? DateFormat::DEFAULT;
144
-                        $timeFormat = Localization::firstOrFail()->time_format->value ?? TimeFormat::DEFAULT;
145
-
146
-                        return $record->start_date ? $record->start_date->format("{$dateFormat} {$timeFormat}") : 'N/A';
147
-                    })
148
-                    ->searchable()
149
-                    ->sortable(),
150
-                Tables\Columns\TextColumn::make('end_date')
151
-                    ->localizeLabel()
152
-                    ->formatStateUsing(static function (Discount $record) {
153
-                        $dateFormat = Localization::firstOrFail()->date_format->value ?? DateFormat::DEFAULT;
154
-                        $timeFormat = Localization::firstOrFail()->time_format->value ?? TimeFormat::DEFAULT;
155
-
156
-                        return $record->end_date ? $record->end_date->format("{$dateFormat} {$timeFormat}") : 'N/A';
157
-                    })
158
-                    ->color(static fn (Discount $record) => $record->end_date?->isPast() ? 'danger' : null)
159
-                    ->searchable()
160
-                    ->sortable(),
161
-            ])
162
-            ->filters([
163
-                //
164
-            ])
165
-            ->actions([
166
-                Tables\Actions\EditAction::make(),
167
-            ])
168
-            ->bulkActions([
169
-                Tables\Actions\BulkActionGroup::make([
170
-                    Tables\Actions\DeleteBulkAction::make(),
171
-                ]),
172
-            ])
173
-            ->checkIfRecordIsSelectableUsing(static function (Discount $record) {
174
-                return $record->isDisabled();
175
-            })
176
-            ->emptyStateActions([
177
-                Tables\Actions\CreateAction::make(),
178
-            ]);
179
-    }
180
-
181
-    public static function getPages(): array
182
-    {
183
-        return [
184
-            'index' => Pages\ListDiscounts::route('/'),
185
-            'create' => Pages\CreateDiscount::route('/create'),
186
-            'edit' => Pages\EditDiscount::route('/{record}/edit'),
187
-        ];
188
-    }
189
-}

+ 0
- 16
app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/CreateDiscount.php View File

@@ -1,16 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource;
6
-use Filament\Resources\Pages\CreateRecord;
7
-
8
-class CreateDiscount extends CreateRecord
9
-{
10
-    protected static string $resource = DiscountResource::class;
11
-
12
-    protected function getRedirectUrl(): string
13
-    {
14
-        return $this->getResource()::getUrl('index');
15
-    }
16
-}

+ 0
- 24
app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/EditDiscount.php View File

@@ -1,24 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource;
6
-use Filament\Actions;
7
-use Filament\Resources\Pages\EditRecord;
8
-
9
-class EditDiscount extends EditRecord
10
-{
11
-    protected static string $resource = DiscountResource::class;
12
-
13
-    protected function getHeaderActions(): array
14
-    {
15
-        return [
16
-            Actions\DeleteAction::make(),
17
-        ];
18
-    }
19
-
20
-    protected function getRedirectUrl(): string
21
-    {
22
-        return $this->getResource()::getUrl('index');
23
-    }
24
-}

+ 0
- 158
app/Filament/Company/Clusters/Settings/Resources/TaxResource.php View File

@@ -1,158 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources;
4
-
5
-use App\Concerns\NotifiesOnDelete;
6
-use App\Enums\Setting\TaxComputation;
7
-use App\Enums\Setting\TaxScope;
8
-use App\Enums\Setting\TaxType;
9
-use App\Filament\Company\Clusters\Settings;
10
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
11
-use App\Models\Setting\Tax;
12
-use Closure;
13
-use Filament\Forms;
14
-use Filament\Forms\Form;
15
-use Filament\Resources\Resource;
16
-use Filament\Support\Enums\FontWeight;
17
-use Filament\Tables;
18
-use Filament\Tables\Table;
19
-use Wallo\FilamentSelectify\Components\ToggleButton;
20
-
21
-class TaxResource extends Resource
22
-{
23
-    use NotifiesOnDelete;
24
-
25
-    protected static ?string $model = Tax::class;
26
-
27
-    protected static ?string $modelLabel = 'Tax';
28
-
29
-    protected static ?string $cluster = Settings::class;
30
-
31
-    public static function getModelLabel(): string
32
-    {
33
-        $modelLabel = static::$modelLabel;
34
-
35
-        return translate($modelLabel);
36
-    }
37
-
38
-    public static function form(Form $form): Form
39
-    {
40
-        return $form
41
-            ->schema([
42
-                Forms\Components\Section::make('General')
43
-                    ->schema([
44
-                        Forms\Components\TextInput::make('name')
45
-                            ->autofocus()
46
-                            ->required()
47
-                            ->localizeLabel()
48
-                            ->maxLength(255)
49
-                            ->rule(static function (Forms\Get $get, Forms\Components\Component $component): Closure {
50
-                                return static function (string $attribute, $value, Closure $fail) use ($component, $get) {
51
-                                    $existingTax = Tax::where('name', $value)
52
-                                        ->whereKeyNot($component->getRecord()?->getKey())
53
-                                        ->where('type', $get('type'))
54
-                                        ->first();
55
-
56
-                                    if ($existingTax) {
57
-                                        $message = translate('The :Type :record ":name" already exists.', [
58
-                                            'Type' => $existingTax->type->getLabel(),
59
-                                            'record' => strtolower(static::getModelLabel()),
60
-                                            'name' => $value,
61
-                                        ]);
62
-
63
-                                        $fail($message);
64
-                                    }
65
-                                };
66
-                            }),
67
-                        Forms\Components\TextInput::make('description'),
68
-                        Forms\Components\Select::make('computation')
69
-                            ->localizeLabel()
70
-                            ->options(TaxComputation::class)
71
-                            ->default(TaxComputation::Percentage)
72
-                            ->live()
73
-                            ->required(),
74
-                        Forms\Components\TextInput::make('rate')
75
-                            ->localizeLabel()
76
-                            ->rate(static fn (Forms\Get $get) => $get('computation'))
77
-                            ->required(),
78
-                        Forms\Components\Select::make('type')
79
-                            ->localizeLabel()
80
-                            ->options(TaxType::class)
81
-                            ->default(TaxType::Sales)
82
-                            ->required(),
83
-                        Forms\Components\Select::make('scope')
84
-                            ->localizeLabel()
85
-                            ->options(TaxScope::class),
86
-                        ToggleButton::make('enabled')
87
-                            ->localizeLabel('Default')
88
-                            ->onLabel(Tax::enabledLabel())
89
-                            ->offLabel(Tax::disabledLabel()),
90
-                    ])->columns(),
91
-            ]);
92
-    }
93
-
94
-    public static function table(Table $table): Table
95
-    {
96
-        return $table
97
-            ->columns([
98
-                Tables\Columns\TextColumn::make('name')
99
-                    ->localizeLabel()
100
-                    ->weight(FontWeight::Medium)
101
-                    ->icon(static fn (Tax $record) => $record->isEnabled() ? 'heroicon-o-lock-closed' : null)
102
-                    ->tooltip(static function (Tax $record) {
103
-                        if ($record->isDisabled()) {
104
-                            return null;
105
-                        }
106
-
107
-                        return translate('Default :Type :Record', [
108
-                            'Type' => $record->type->getLabel(),
109
-                            'Record' => static::getModelLabel(),
110
-                        ]);
111
-                    })
112
-                    ->iconPosition('after')
113
-                    ->searchable()
114
-                    ->sortable(),
115
-                Tables\Columns\TextColumn::make('computation')
116
-                    ->localizeLabel()
117
-                    ->searchable()
118
-                    ->sortable(),
119
-                Tables\Columns\TextColumn::make('rate')
120
-                    ->localizeLabel()
121
-                    ->rate(static fn (Tax $record) => $record->computation->value)
122
-                    ->searchable()
123
-                    ->sortable(),
124
-                Tables\Columns\TextColumn::make('type')
125
-                    ->localizeLabel()
126
-                    ->badge()
127
-                    ->searchable()
128
-                    ->sortable(),
129
-            ])
130
-            ->filters([
131
-                //
132
-            ])
133
-            ->actions([
134
-                Tables\Actions\EditAction::make(),
135
-                Tables\Actions\DeleteAction::make(),
136
-            ])
137
-            ->bulkActions([
138
-                Tables\Actions\BulkActionGroup::make([
139
-                    Tables\Actions\DeleteBulkAction::make(),
140
-                ]),
141
-            ])
142
-            ->checkIfRecordIsSelectableUsing(static function (Tax $record) {
143
-                return $record->isDisabled();
144
-            })
145
-            ->emptyStateActions([
146
-                Tables\Actions\CreateAction::make(),
147
-            ]);
148
-    }
149
-
150
-    public static function getPages(): array
151
-    {
152
-        return [
153
-            'index' => Pages\ListTaxes::route('/'),
154
-            'create' => Pages\CreateTax::route('/create'),
155
-            'edit' => Pages\EditTax::route('/{record}/edit'),
156
-        ];
157
-    }
158
-}

+ 0
- 16
app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/CreateTax.php View File

@@ -1,16 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource;
6
-use Filament\Resources\Pages\CreateRecord;
7
-
8
-class CreateTax extends CreateRecord
9
-{
10
-    protected static string $resource = TaxResource::class;
11
-
12
-    protected function getRedirectUrl(): string
13
-    {
14
-        return $this->getResource()::getUrl('index');
15
-    }
16
-}

+ 0
- 24
app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/EditTax.php View File

@@ -1,24 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource;
6
-use Filament\Actions;
7
-use Filament\Resources\Pages\EditRecord;
8
-
9
-class EditTax extends EditRecord
10
-{
11
-    protected static string $resource = TaxResource::class;
12
-
13
-    protected function getHeaderActions(): array
14
-    {
15
-        return [
16
-            Actions\DeleteAction::make(),
17
-        ];
18
-    }
19
-
20
-    protected function getRedirectUrl(): string
21
-    {
22
-        return $this->getResource()::getUrl('index');
23
-    }
24
-}

+ 2
- 0
app/Filament/Company/Pages/Accounting/AccountChart.php View File

@@ -46,6 +46,8 @@ class AccountChart extends Page
46 46
     public function categories(): Collection
47 47
     {
48 48
         return AccountSubtype::withCount('accounts')
49
+            ->with('accounts')
50
+            ->with('accounts.adjustment')
49 51
             ->get()
50 52
             ->groupBy('category');
51 53
     }

+ 22
- 15
app/Filament/Company/Pages/Accounting/Transactions.php View File

@@ -87,6 +87,11 @@ class Transactions extends Page implements HasTable
87 87
         return static::getModel()::query();
88 88
     }
89 89
 
90
+    public function getMaxContentWidth(): MaxWidth | string | null
91
+    {
92
+        return 'max-w-8xl';
93
+    }
94
+
90 95
     protected function getHeaderActions(): array
91 96
     {
92 97
         return [
@@ -262,7 +267,11 @@ class Transactions extends Page implements HasTable
262 267
                     'account',
263 268
                     'bankAccount.account',
264 269
                     'journalEntries.account',
265
-                ]);
270
+                ])
271
+                    ->where(function (Builder $query) {
272
+                        $query->whereNull('transactionable_id')
273
+                            ->orWhere('is_payment', true);
274
+                    });
266 275
             })
267 276
             ->columns([
268 277
                 Tables\Columns\TextColumn::make('posted_at')
@@ -275,7 +284,7 @@ class Transactions extends Page implements HasTable
275 284
                     ->toggleable(isToggledHiddenByDefault: true),
276 285
                 Tables\Columns\TextColumn::make('description')
277 286
                     ->label('Description')
278
-                    ->limit(30)
287
+                    ->limit(50)
279 288
                     ->toggleable(),
280 289
                 Tables\Columns\TextColumn::make('bankAccount.account.name')
281 290
                     ->label('Account')
@@ -296,7 +305,7 @@ class Transactions extends Page implements HasTable
296 305
                         }
297 306
                     )
298 307
                     ->sortable()
299
-                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
308
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
300 309
             ])
301 310
             ->recordClasses(static fn (Transaction $transaction) => $transaction->reviewed ? 'bg-primary-300/10' : null)
302 311
             ->defaultSort('posted_at', 'desc')
@@ -320,7 +329,7 @@ class Transactions extends Page implements HasTable
320 329
                     ->options(TransactionType::class),
321 330
                 $this->buildDateRangeFilter('posted_at', 'Posted', true),
322 331
                 $this->buildDateRangeFilter('updated_at', 'Last Modified'),
323
-            ], layout: Tables\Enums\FiltersLayout::Modal)
332
+            ])
324 333
             ->filtersFormSchema(fn (array $filters): array => [
325 334
                 Grid::make()
326 335
                     ->schema([
@@ -770,19 +779,17 @@ class Transactions extends Page implements HasTable
770 779
 
771 780
     protected function getBankAccountOptions(?int $excludedAccountId = null, ?int $currentBankAccountId = null): array
772 781
     {
773
-        return BankAccount::join('accounts', 'accounts.bank_account_id', '=', 'bank_accounts.id')
774
-            ->where('accounts.archived', false)
775
-            ->select(['bank_accounts.id', 'accounts.name', 'accounts.subtype_id'])
776
-            ->with(['account.subtype' => static function ($query) {
782
+        return BankAccount::query()
783
+            ->whereHas('account', function (Builder $query) {
784
+                $query->where('archived', false);
785
+            })
786
+            ->with(['account' => function ($query) {
787
+                $query->where('archived', false);
788
+            }, 'account.subtype' => function ($query) {
777 789
                 $query->select(['id', 'name']);
778 790
             }])
779
-            ->when($excludedAccountId, function (Builder $query) use ($excludedAccountId) {
780
-                $query->whereNot('accounts.id', $excludedAccountId);
781
-            })
782
-            ->when($currentBankAccountId, function (Builder $query) use ($currentBankAccountId) {
783
-                // Ensure the current bank account is included even if archived
784
-                $query->orWhere('bank_accounts.id', $currentBankAccountId);
785
-            })
791
+            ->when($excludedAccountId, fn (Builder $query) => $query->where('account_id', '!=', $excludedAccountId))
792
+            ->when($currentBankAccountId, fn (Builder $query) => $query->orWhere('id', $currentBankAccountId))
786 793
             ->get()
787 794
             ->groupBy('account.subtype.name')
788 795
             ->map(fn (Collection $bankAccounts, string $subtype) => $bankAccounts->pluck('account.name', 'id'))

+ 24
- 0
app/Filament/Company/Pages/Concerns/HasReportTabs.php View File

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Concerns;
4
+
5
+use Livewire\Attributes\Url;
6
+
7
+trait HasReportTabs
8
+{
9
+    #[Url]
10
+    public ?string $activeTab = 'summary';
11
+
12
+    public function getTabs(): array
13
+    {
14
+        return [
15
+            'summary' => 'Summary',
16
+            'details' => 'Details',
17
+        ];
18
+    }
19
+
20
+    public function getActiveTab(): string
21
+    {
22
+        return $this->activeTab;
23
+    }
24
+}

+ 22
- 0
app/Filament/Company/Pages/CreateCompany.php View File

@@ -11,6 +11,8 @@ use App\Utilities\Currency\CurrencyAccessor;
11 11
 use Filament\Forms\Components\Select;
12 12
 use Filament\Forms\Components\TextInput;
13 13
 use Filament\Forms\Form;
14
+use Filament\Support\Enums\MaxWidth;
15
+use Illuminate\Contracts\Support\Htmlable;
14 16
 use Illuminate\Database\Eloquent\Model;
15 17
 use Illuminate\Support\Facades\Auth;
16 18
 use Illuminate\Support\Facades\DB;
@@ -23,6 +25,25 @@ class CreateCompany extends FilamentCreateCompany
23 25
 {
24 26
     protected bool $hasTopbar = false;
25 27
 
28
+    protected static string $view = 'filament.company.pages.create-company';
29
+
30
+    protected static string $layout = 'components.company.layout.custom-simple';
31
+
32
+    public function getHeading(): string | Htmlable
33
+    {
34
+        return '';
35
+    }
36
+
37
+    public function getMaxWidth(): MaxWidth | string | null
38
+    {
39
+        return MaxWidth::FourExtraLarge;
40
+    }
41
+
42
+    public function hasLogo(): bool
43
+    {
44
+        return true;
45
+    }
46
+
26 47
     public function form(Form $form): Form
27 48
     {
28 49
         return $form
@@ -58,6 +79,7 @@ class CreateCompany extends FilamentCreateCompany
58 79
                     ->optionsLimit(5)
59 80
                     ->softRequired(),
60 81
             ])
82
+            ->columns()
61 83
             ->model(FilamentCompanies::companyModel())
62 84
             ->statePath('data');
63 85
     }

+ 28
- 8
app/Filament/Company/Pages/Reports.php View File

@@ -11,6 +11,7 @@ use App\Filament\Company\Pages\Reports\TrialBalance;
11 11
 use App\Infolists\Components\ReportEntry;
12 12
 use Filament\Infolists\Components\Section;
13 13
 use Filament\Infolists\Infolist;
14
+use Filament\Navigation\NavigationItem;
14 15
 use Filament\Pages\Page;
15 16
 use Filament\Support\Colors\Color;
16 17
 
@@ -20,6 +21,25 @@ class Reports extends Page
20 21
 
21 22
     protected static string $view = 'filament.company.pages.reports';
22 23
 
24
+    public static function getNavigationItems(): array
25
+    {
26
+        return [
27
+            NavigationItem::make(static::getNavigationLabel())
28
+                ->group(static::getNavigationGroup())
29
+                ->parentItem(static::getNavigationParentItem())
30
+                ->icon(static::getNavigationIcon())
31
+                ->activeIcon(static::getActiveNavigationIcon())
32
+                ->isActiveWhen(fn (): bool => request()->routeIs([
33
+                    static::getRouteName(),
34
+                    static::getRouteName() . '.*',
35
+                ]))
36
+                ->sort(static::getNavigationSort())
37
+                ->badge(static::getNavigationBadge(), color: static::getNavigationBadgeColor())
38
+                ->badgeTooltip(static::getNavigationBadgeTooltip())
39
+                ->url(static::getNavigationUrl()),
40
+        ];
41
+    }
42
+
23 43
     public function reportsInfolist(Infolist $infolist): Infolist
24 44
     {
25 45
         return $infolist
@@ -27,54 +47,54 @@ class Reports extends Page
27 47
             ->schema([
28 48
                 Section::make('Financial Statements')
29 49
                     ->aside()
30
-                    ->description('Key financial statements that provide a snapshot of your company’s financial health.')
50
+                    ->description('Key financial statements that provide an overview of your company’s financial health and performance.')
31 51
                     ->extraAttributes(['class' => 'es-report-card'])
32 52
                     ->schema([
33 53
                         ReportEntry::make('income_statement')
34 54
                             ->hiddenLabel()
35 55
                             ->heading('Income Statement')
36
-                            ->description('Tracks revenue and expenses to show profit or loss over a specific period of time.')
56
+                            ->description('Shows revenue, expenses, and net earnings over a period, indicating overall financial performance.')
37 57
                             ->icon('heroicon-o-chart-bar')
38 58
                             ->iconColor(Color::Indigo)
39 59
                             ->url(IncomeStatement::getUrl()),
40 60
                         ReportEntry::make('balance_sheet')
41 61
                             ->hiddenLabel()
42 62
                             ->heading('Balance Sheet')
43
-                            ->description('Snapshot of assets, liabilities, and equity at a specific point in time.')
63
+                            ->description('Displays your company’s assets, liabilities, and equity at a single point in time, showing overall financial health and stability.')
44 64
                             ->icon('heroicon-o-clipboard-document-list')
45 65
                             ->iconColor(Color::Emerald)
46 66
                             ->url(BalanceSheet::getUrl()),
47 67
                         ReportEntry::make('cash_flow_statement')
48 68
                             ->hiddenLabel()
49 69
                             ->heading('Cash Flow Statement')
50
-                            ->description('Shows cash inflows and outflows over a specific period of time.')
70
+                            ->description('Tracks cash inflows and outflows, giving insight into liquidity and cash management over a period.')
51 71
                             ->icon('heroicon-o-document-currency-dollar')
52 72
                             ->iconColor(Color::Cyan)
53 73
                             ->url(CashFlowStatement::getUrl()),
54 74
                     ]),
55 75
                 Section::make('Detailed Reports')
56 76
                     ->aside()
57
-                    ->description('Dig into the details of your company’s transactions, balances, and accounts.')
77
+                    ->description('Detailed reports that provide a comprehensive view of your company’s financial transactions and account balances.')
58 78
                     ->extraAttributes(['class' => 'es-report-card'])
59 79
                     ->schema([
60 80
                         ReportEntry::make('account_balances')
61 81
                             ->hiddenLabel()
62 82
                             ->heading('Account Balances')
63
-                            ->description('Summary view of balances and activity for all accounts.')
83
+                            ->description('Lists all accounts and their balances, including starting, debit, credit, net movement, and ending balances.')
64 84
                             ->icon('heroicon-o-currency-dollar')
65 85
                             ->iconColor(Color::Teal)
66 86
                             ->url(AccountBalances::getUrl()),
67 87
                         ReportEntry::make('trial_balance')
68 88
                             ->hiddenLabel()
69 89
                             ->heading('Trial Balance')
70
-                            ->description('The sum of all debit and credit balances for all accounts on a single day. This helps to ensure that the books are in balance.')
90
+                            ->description('Summarizes all account debits and credits on a specific date to verify the ledger is balanced.')
71 91
                             ->icon('heroicon-o-scale')
72 92
                             ->iconColor(Color::Sky)
73 93
                             ->url(TrialBalance::getUrl()),
74 94
                         ReportEntry::make('account_transactions')
75 95
                             ->hiddenLabel()
76 96
                             ->heading('Account Transactions')
77
-                            ->description('A record of all transactions for a company. The general ledger is the core of a company\'s financial records.')
97
+                            ->description('A record of all transactions, essential for monitoring and reconciling financial activity in the ledger.')
78 98
                             ->icon('heroicon-o-adjustments-horizontal')
79 99
                             ->iconColor(Color::Amber)
80 100
                             ->url(AccountTransactions::getUrl()),

+ 0
- 4
app/Filament/Company/Pages/Reports/AccountBalances.php View File

@@ -17,10 +17,6 @@ class AccountBalances extends BaseReportPage
17 17
 {
18 18
     protected static string $view = 'filament.company.pages.reports.detailed-report';
19 19
 
20
-    protected static ?string $slug = 'reports/account-balances';
21
-
22
-    protected static bool $shouldRegisterNavigation = false;
23
-
24 20
     protected ReportService $reportService;
25 21
 
26 22
     protected ExportService $exportService;

+ 43
- 7
app/Filament/Company/Pages/Reports/AccountTransactions.php View File

@@ -7,6 +7,8 @@ use App\DTO\ReportDTO;
7 7
 use App\Filament\Company\Pages\Accounting\Transactions;
8 8
 use App\Models\Accounting\Account;
9 9
 use App\Models\Accounting\JournalEntry;
10
+use App\Models\Common\Client;
11
+use App\Models\Common\Vendor;
10 12
 use App\Services\ExportService;
11 13
 use App\Services\ReportService;
12 14
 use App\Support\Column;
@@ -27,10 +29,6 @@ class AccountTransactions extends BaseReportPage
27 29
 {
28 30
     protected static string $view = 'filament.company.pages.reports.account-transactions';
29 31
 
30
-    protected static ?string $slug = 'reports/account-transactions';
31
-
32
-    protected static bool $shouldRegisterNavigation = false;
33
-
34 32
     protected ReportService $reportService;
35 33
 
36 34
     protected ExportService $exportService;
@@ -43,7 +41,7 @@ class AccountTransactions extends BaseReportPage
43 41
 
44 42
     public function getMaxContentWidth(): MaxWidth | string | null
45 43
     {
46
-        return 'max-w-[90rem]';
44
+        return 'max-w-8xl';
47 45
     }
48 46
 
49 47
     protected function initializeDefaultFilters(): void
@@ -51,6 +49,10 @@ class AccountTransactions extends BaseReportPage
51 49
         if (empty($this->getFilterState('selectedAccount'))) {
52 50
             $this->setFilterState('selectedAccount', 'all');
53 51
         }
52
+
53
+        if (empty($this->getFilterState('selectedEntity'))) {
54
+            $this->setFilterState('selectedEntity', 'all');
55
+        }
54 56
     }
55 57
 
56 58
     /**
@@ -81,7 +83,7 @@ class AccountTransactions extends BaseReportPage
81 83
     public function filtersForm(Form $form): Form
82 84
     {
83 85
         return $form
84
-            ->columns(4)
86
+            ->columns(5)
85 87
             ->schema([
86 88
                 Select::make('selectedAccount')
87 89
                     ->label('Account')
@@ -95,6 +97,11 @@ class AccountTransactions extends BaseReportPage
95 97
                 ])->extraFieldWrapperAttributes([
96 98
                     'class' => 'report-hidden-label',
97 99
                 ]),
100
+                Select::make('selectedEntity')
101
+                    ->label('Entity')
102
+                    ->options($this->getEntityOptions())
103
+                    ->searchable()
104
+                    ->selectablePlaceholder(false),
98 105
                 Actions::make([
99 106
                     Actions\Action::make('applyFilters')
100 107
                         ->label('Update Report')
@@ -120,9 +127,38 @@ class AccountTransactions extends BaseReportPage
120 127
         return $allAccountsOption + $accounts;
121 128
     }
122 129
 
130
+    protected function getEntityOptions(): array
131
+    {
132
+        $clients = Client::query()
133
+            ->orderBy('name')
134
+            ->pluck('name', 'id')
135
+            ->toArray();
136
+
137
+        $vendors = Vendor::query()
138
+            ->orderBy('name')
139
+            ->pluck('name', 'id')
140
+            ->mapWithKeys(fn ($name, $id) => [-$id => $name])
141
+            ->toArray();
142
+
143
+        $allEntitiesOption = [
144
+            'All Entities' => ['all' => 'All Entities'],
145
+        ];
146
+
147
+        return $allEntitiesOption + [
148
+            'Clients' => $clients,
149
+            'Vendors' => $vendors,
150
+        ];
151
+    }
152
+
123 153
     protected function buildReport(array $columns): ReportDTO
124 154
     {
125
-        return $this->reportService->buildAccountTransactionsReport($this->getFormattedStartDate(), $this->getFormattedEndDate(), $columns, $this->getFilterState('selectedAccount'));
155
+        return $this->reportService->buildAccountTransactionsReport(
156
+            startDate: $this->getFormattedStartDate(),
157
+            endDate: $this->getFormattedEndDate(),
158
+            columns: $columns,
159
+            accountId: $this->getFilterState('selectedAccount'),
160
+            entityId: $this->getFilterState('selectedEntity'),
161
+        );
126 162
     }
127 163
 
128 164
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport

+ 5
- 8
app/Filament/Company/Pages/Reports/BalanceSheet.php View File

@@ -4,6 +4,7 @@ namespace App\Filament\Company\Pages\Reports;
4 4
 
5 5
 use App\Contracts\ExportableReport;
6 6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasReportTabs;
7 8
 use App\Filament\Forms\Components\DateRangeSelect;
8 9
 use App\Services\ExportService;
9 10
 use App\Services\ReportService;
@@ -11,22 +12,18 @@ use App\Support\Column;
11 12
 use App\Transformers\BalanceSheetReportTransformer;
12 13
 use Filament\Forms\Form;
13 14
 use Filament\Support\Enums\Alignment;
14
-use Livewire\Attributes\Url;
15 15
 use Symfony\Component\HttpFoundation\StreamedResponse;
16 16
 
17 17
 class BalanceSheet extends BaseReportPage
18 18
 {
19
-    protected static string $view = 'filament.company.pages.reports.balance-sheet';
19
+    use HasReportTabs;
20 20
 
21
-    protected static bool $shouldRegisterNavigation = false;
21
+    protected static string $view = 'filament.company.pages.reports.balance-sheet';
22 22
 
23 23
     protected ReportService $reportService;
24 24
 
25 25
     protected ExportService $exportService;
26 26
 
27
-    #[Url]
28
-    public ?string $activeTab = 'summary';
29
-
30 27
     public function boot(ReportService $reportService, ExportService $exportService): void
31 28
     {
32 29
         $this->reportService = $reportService;
@@ -77,11 +74,11 @@ class BalanceSheet extends BaseReportPage
77 74
 
78 75
     public function exportCSV(): StreamedResponse
79 76
     {
80
-        return $this->exportService->exportToCsv($this->company, $this->report, endDate: $this->getFilterState('asOfDate'));
77
+        return $this->exportService->exportToCsv($this->company, $this->report, endDate: $this->getFilterState('asOfDate'), activeTab: $this->getActiveTab());
81 78
     }
82 79
 
83 80
     public function exportPDF(): StreamedResponse
84 81
     {
85
-        return $this->exportService->exportToPdf($this->company, $this->report, endDate: $this->getFilterState('asOfDate'));
82
+        return $this->exportService->exportToPdf($this->company, $this->report, endDate: $this->getFilterState('asOfDate'), activeTab: $this->getActiveTab());
86 83
     }
87 84
 }

+ 26
- 0
app/Filament/Company/Pages/Reports/BaseReportPage.php View File

@@ -6,6 +6,7 @@ use App\Contracts\ExportableReport;
6 6
 use App\DTO\ReportDTO;
7 7
 use App\Filament\Company\Pages\Concerns\HasDeferredFiltersForm;
8 8
 use App\Filament\Company\Pages\Concerns\HasTableColumnToggleForm;
9
+use App\Filament\Company\Pages\Reports;
9 10
 use App\Filament\Forms\Components\DateRangeSelect;
10 11
 use App\Models\Company;
11 12
 use App\Services\DateRangeService;
@@ -62,6 +63,31 @@ abstract class BaseReportPage extends Page
62 63
         $this->fiscalYearEndDate = $this->company->locale->fiscalYearEndDate();
63 64
     }
64 65
 
66
+    public static function shouldRegisterNavigation(): bool
67
+    {
68
+        return false;
69
+    }
70
+
71
+    public static function getSlug(): string
72
+    {
73
+        $prefix = Reports::getSlug() . '/';
74
+
75
+        if (filled(static::$slug)) {
76
+            return $prefix . static::$slug;
77
+        }
78
+
79
+        return $prefix . str(class_basename(static::class))
80
+            ->kebab()
81
+            ->slug();
82
+    }
83
+
84
+    public function getBreadcrumbs(): array
85
+    {
86
+        return [
87
+            Reports::getUrl() => Reports::getNavigationLabel(),
88
+        ];
89
+    }
90
+
65 91
     protected function loadDefaultDateRange(): void
66 92
     {
67 93
         $flatFields = $this->getFiltersForm()->getFlatFields();

+ 5
- 10
app/Filament/Company/Pages/Reports/CashFlowStatement.php View File

@@ -4,6 +4,7 @@ namespace App\Filament\Company\Pages\Reports;
4 4
 
5 5
 use App\Contracts\ExportableReport;
6 6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasReportTabs;
7 8
 use App\Services\ExportService;
8 9
 use App\Services\ReportService;
9 10
 use App\Support\Column;
@@ -11,24 +12,18 @@ use App\Transformers\CashFlowStatementReportTransformer;
11 12
 use Filament\Forms\Form;
12 13
 use Filament\Support\Enums\Alignment;
13 14
 use Guava\FilamentClusters\Forms\Cluster;
14
-use Livewire\Attributes\Url;
15 15
 use Symfony\Component\HttpFoundation\StreamedResponse;
16 16
 
17 17
 class CashFlowStatement extends BaseReportPage
18 18
 {
19
-    protected static string $view = 'filament.company.pages.reports.cash-flow-statement';
20
-
21
-    protected static ?string $slug = 'reports/cash-flow-statement';
19
+    use HasReportTabs;
22 20
 
23
-    protected static bool $shouldRegisterNavigation = false;
21
+    protected static string $view = 'filament.company.pages.reports.cash-flow-statement';
24 22
 
25 23
     protected ReportService $reportService;
26 24
 
27 25
     protected ExportService $exportService;
28 26
 
29
-    #[Url]
30
-    public ?string $activeTab = 'summary';
31
-
32 27
     public function boot(ReportService $reportService, ExportService $exportService): void
33 28
     {
34 29
         $this->reportService = $reportService;
@@ -77,11 +72,11 @@ class CashFlowStatement extends BaseReportPage
77 72
 
78 73
     public function exportCSV(): StreamedResponse
79 74
     {
80
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
75
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
81 76
     }
82 77
 
83 78
     public function exportPDF(): StreamedResponse
84 79
     {
85
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
80
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
86 81
     }
87 82
 }

+ 5
- 10
app/Filament/Company/Pages/Reports/IncomeStatement.php View File

@@ -4,6 +4,7 @@ namespace App\Filament\Company\Pages\Reports;
4 4
 
5 5
 use App\Contracts\ExportableReport;
6 6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasReportTabs;
7 8
 use App\Services\ExportService;
8 9
 use App\Services\ReportService;
9 10
 use App\Support\Column;
@@ -11,24 +12,18 @@ use App\Transformers\IncomeStatementReportTransformer;
11 12
 use Filament\Forms\Form;
12 13
 use Filament\Support\Enums\Alignment;
13 14
 use Guava\FilamentClusters\Forms\Cluster;
14
-use Livewire\Attributes\Url;
15 15
 use Symfony\Component\HttpFoundation\StreamedResponse;
16 16
 
17 17
 class IncomeStatement extends BaseReportPage
18 18
 {
19
-    protected static string $view = 'filament.company.pages.reports.income-statement';
20
-
21
-    protected static ?string $slug = 'reports/income-statement';
19
+    use HasReportTabs;
22 20
 
23
-    protected static bool $shouldRegisterNavigation = false;
21
+    protected static string $view = 'filament.company.pages.reports.income-statement';
24 22
 
25 23
     protected ReportService $reportService;
26 24
 
27 25
     protected ExportService $exportService;
28 26
 
29
-    #[Url]
30
-    public ?string $activeTab = 'summary';
31
-
32 27
     public function boot(ReportService $reportService, ExportService $exportService): void
33 28
     {
34 29
         $this->reportService = $reportService;
@@ -77,11 +72,11 @@ class IncomeStatement extends BaseReportPage
77 72
 
78 73
     public function exportCSV(): StreamedResponse
79 74
     {
80
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
75
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
81 76
     }
82 77
 
83 78
     public function exportPDF(): StreamedResponse
84 79
     {
85
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
80
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
86 81
     }
87 82
 }

+ 0
- 4
app/Filament/Company/Pages/Reports/TrialBalance.php View File

@@ -18,10 +18,6 @@ class TrialBalance extends BaseReportPage
18 18
 {
19 19
     protected static string $view = 'filament.company.pages.reports.trial-balance';
20 20
 
21
-    protected static ?string $slug = 'reports/trial-balance';
22
-
23
-    protected static bool $shouldRegisterNavigation = false;
24
-
25 21
     protected ReportService $reportService;
26 22
 
27 23
     protected ExportService $exportService;

+ 6
- 0
app/Filament/Company/Resources/Banking/AccountResource/Pages/ListAccounts.php View File

@@ -5,6 +5,7 @@ namespace App\Filament\Company\Resources\Banking\AccountResource\Pages;
5 5
 use App\Filament\Company\Resources\Banking\AccountResource;
6 6
 use Filament\Actions;
7 7
 use Filament\Resources\Pages\ListRecords;
8
+use Filament\Support\Enums\MaxWidth;
8 9
 
9 10
 class ListAccounts extends ListRecords
10 11
 {
@@ -16,4 +17,9 @@ class ListAccounts extends ListRecords
16 17
             Actions\CreateAction::make(),
17 18
         ];
18 19
     }
20
+
21
+    public function getMaxContentWidth(): MaxWidth | string | null
22
+    {
23
+        return 'max-w-8xl';
24
+    }
19 25
 }

+ 195
- 0
app/Filament/Company/Resources/Common/OfferingResource.php View File

@@ -0,0 +1,195 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Common;
4
+
5
+use App\Enums\Accounting\AccountCategory;
6
+use App\Enums\Accounting\AccountType;
7
+use App\Enums\Common\OfferingType;
8
+use App\Filament\Company\Resources\Common\OfferingResource\Pages;
9
+use App\Models\Accounting\Account;
10
+use App\Models\Common\Offering;
11
+use App\Utilities\Currency\CurrencyAccessor;
12
+use Filament\Forms;
13
+use Filament\Forms\Form;
14
+use Filament\Resources\Resource;
15
+use Filament\Tables;
16
+use Filament\Tables\Table;
17
+use Illuminate\Database\Eloquent\Builder;
18
+use Illuminate\Support\Str;
19
+use JaOcero\RadioDeck\Forms\Components\RadioDeck;
20
+
21
+class OfferingResource extends Resource
22
+{
23
+    protected static ?string $model = Offering::class;
24
+
25
+    protected static ?string $modelLabel = 'Offering';
26
+
27
+    protected static ?string $navigationIcon = 'heroicon-o-square-3-stack-3d';
28
+
29
+    public static function form(Form $form): Form
30
+    {
31
+        return $form
32
+            ->schema([
33
+                Forms\Components\Section::make('General')
34
+                    ->schema([
35
+                        RadioDeck::make('type')
36
+                            ->options(OfferingType::class)
37
+                            ->default(OfferingType::Product)
38
+                            ->icons(OfferingType::class)
39
+                            ->color('primary')
40
+                            ->columns()
41
+                            ->required(),
42
+                        Forms\Components\TextInput::make('name')
43
+                            ->autofocus()
44
+                            ->required()
45
+                            ->columnStart(1)
46
+                            ->maxLength(255),
47
+                        Forms\Components\TextInput::make('price')
48
+                            ->required()
49
+                            ->money(),
50
+                        Forms\Components\Textarea::make('description')
51
+                            ->label('Description')
52
+                            ->columnSpan(2)
53
+                            ->rows(3),
54
+                        Forms\Components\CheckboxList::make('attributes')
55
+                            ->options([
56
+                                'Sellable' => 'Sellable',
57
+                                'Purchasable' => 'Purchasable',
58
+                            ])
59
+                            ->hiddenLabel()
60
+                            ->required()
61
+                            ->live()
62
+                            ->bulkToggleable()
63
+                            ->validationMessages([
64
+                                'required' => 'The offering must be either sellable or purchasable.',
65
+                            ]),
66
+                    ])->columns(),
67
+                // Sellable Section
68
+                Forms\Components\Section::make('Sale Information')
69
+                    ->schema([
70
+                        Forms\Components\Select::make('income_account_id')
71
+                            ->label('Income Account')
72
+                            ->options(Account::query()
73
+                                ->where('category', AccountCategory::Revenue)
74
+                                ->where('type', AccountType::OperatingRevenue)
75
+                                ->pluck('name', 'id')
76
+                                ->toArray())
77
+                            ->searchable()
78
+                            ->preload()
79
+                            ->required()
80
+                            ->validationMessages([
81
+                                'required' => 'The income account is required for sellable offerings.',
82
+                            ]),
83
+                        Forms\Components\Select::make('salesTaxes')
84
+                            ->label('Sales Tax')
85
+                            ->relationship('salesTaxes', 'name')
86
+                            ->preload()
87
+                            ->multiple(),
88
+                        Forms\Components\Select::make('salesDiscounts')
89
+                            ->label('Sales Discount')
90
+                            ->relationship('salesDiscounts', 'name')
91
+                            ->preload()
92
+                            ->multiple(),
93
+                    ])
94
+                    ->columns()
95
+                    ->visible(fn (Forms\Get $get) => in_array('Sellable', $get('attributes') ?? [])),
96
+
97
+                // Purchasable Section
98
+                Forms\Components\Section::make('Purchase Information')
99
+                    ->schema([
100
+                        Forms\Components\Select::make('expense_account_id')
101
+                            ->label('Expense Account')
102
+                            ->options(Account::query()
103
+                                ->where('category', AccountCategory::Expense)
104
+                                ->where('type', AccountType::OperatingExpense)
105
+                                ->orderBy('name')
106
+                                ->pluck('name', 'id')
107
+                                ->toArray())
108
+                            ->searchable()
109
+                            ->preload()
110
+                            ->required()
111
+                            ->validationMessages([
112
+                                'required' => 'The expense account is required for purchasable offerings.',
113
+                            ]),
114
+                        Forms\Components\Select::make('purchaseTaxes')
115
+                            ->label('Purchase Tax')
116
+                            ->relationship('purchaseTaxes', 'name')
117
+                            ->preload()
118
+                            ->multiple(),
119
+                        Forms\Components\Select::make('purchaseDiscounts')
120
+                            ->label('Purchase Discount')
121
+                            ->relationship('purchaseDiscounts', 'name')
122
+                            ->preload()
123
+                            ->multiple(),
124
+                    ])
125
+                    ->columns()
126
+                    ->visible(fn (Forms\Get $get) => in_array('Purchasable', $get('attributes') ?? [])),
127
+            ])->columns();
128
+    }
129
+
130
+    public static function table(Table $table): Table
131
+    {
132
+        return $table
133
+            ->modifyQueryUsing(function (Builder $query) {
134
+                $query->selectRaw("
135
+                        *,
136
+                        CONCAT_WS(' & ',
137
+                            CASE WHEN sellable THEN 'Sellable' END,
138
+                            CASE WHEN purchasable THEN 'Purchasable' END
139
+                        ) AS attributes
140
+                    ");
141
+            })
142
+            ->columns([
143
+                Tables\Columns\TextColumn::make('name')
144
+                    ->label('Name'),
145
+                Tables\Columns\TextColumn::make('attributes')
146
+                    ->label('Attributes')
147
+                    ->badge(),
148
+                Tables\Columns\TextColumn::make('type')
149
+                    ->searchable(),
150
+                Tables\Columns\TextColumn::make('price')
151
+                    ->currency(CurrencyAccessor::getDefaultCurrency(), true)
152
+                    ->sortable()
153
+                    ->description(function (Offering $record) {
154
+                        $adjustments = $record->adjustments()
155
+                            ->pluck('name')
156
+                            ->join(', ');
157
+
158
+                        if (empty($adjustments)) {
159
+                            return null;
160
+                        }
161
+
162
+                        $adjustmentsList = Str::of($adjustments)->limit(40);
163
+
164
+                        return "+ {$adjustmentsList}";
165
+                    }),
166
+            ])
167
+            ->filters([
168
+                //
169
+            ])
170
+            ->actions([
171
+                Tables\Actions\EditAction::make(),
172
+            ])
173
+            ->bulkActions([
174
+                Tables\Actions\BulkActionGroup::make([
175
+                    Tables\Actions\DeleteBulkAction::make(),
176
+                ]),
177
+            ]);
178
+    }
179
+
180
+    public static function getRelations(): array
181
+    {
182
+        return [
183
+            //
184
+        ];
185
+    }
186
+
187
+    public static function getPages(): array
188
+    {
189
+        return [
190
+            'index' => Pages\ListOfferings::route('/'),
191
+            'create' => Pages\CreateOffering::route('/create'),
192
+            'edit' => Pages\EditOffering::route('/{record}/edit'),
193
+        ];
194
+    }
195
+}

+ 27
- 0
app/Filament/Company/Resources/Common/OfferingResource/Pages/CreateOffering.php View File

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Common\OfferingResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Common\OfferingResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Illuminate\Database\Eloquent\Model;
9
+
10
+class CreateOffering extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = OfferingResource::class;
15
+
16
+    protected function handleRecordCreation(array $data): Model
17
+    {
18
+        $attributes = array_flip($data['attributes'] ?? []);
19
+
20
+        $data['sellable'] = isset($attributes['Sellable']);
21
+        $data['purchasable'] = isset($attributes['Purchasable']);
22
+
23
+        unset($data['attributes']);
24
+
25
+        return parent::handleRecordCreation($data); // TODO: Change the autogenerated stub
26
+    }
27
+}

+ 45
- 0
app/Filament/Company/Resources/Common/OfferingResource/Pages/EditOffering.php View File

@@ -0,0 +1,45 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Common\OfferingResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Common\OfferingResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Illuminate\Database\Eloquent\Model;
10
+
11
+class EditOffering extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = OfferingResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    protected function mutateFormDataBeforeFill(array $data): array
25
+    {
26
+        $data['attributes'] = array_filter([
27
+            $data['sellable'] ? 'Sellable' : null,
28
+            $data['purchasable'] ? 'Purchasable' : null,
29
+        ]);
30
+
31
+        return parent::mutateFormDataBeforeFill($data); // TODO: Change the autogenerated stub
32
+    }
33
+
34
+    protected function handleRecordUpdate(Model $record, array $data): Model
35
+    {
36
+        $attributes = array_flip($data['attributes'] ?? []);
37
+
38
+        $data['sellable'] = isset($attributes['Sellable']);
39
+        $data['purchasable'] = isset($attributes['Purchasable']);
40
+
41
+        unset($data['attributes']);
42
+
43
+        return parent::handleRecordUpdate($record, $data); // TODO: Change the autogenerated stub
44
+    }
45
+}

app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/ListTaxes.php → app/Filament/Company/Resources/Common/OfferingResource/Pages/ListOfferings.php View File

@@ -1,15 +1,15 @@
1 1
 <?php
2 2
 
3
-namespace App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
3
+namespace App\Filament\Company\Resources\Common\OfferingResource\Pages;
4 4
 
5
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource;
5
+use App\Filament\Company\Resources\Common\OfferingResource;
6 6
 use Filament\Actions;
7 7
 use Filament\Resources\Pages\ListRecords;
8 8
 use Filament\Support\Enums\MaxWidth;
9 9
 
10
-class ListTaxes extends ListRecords
10
+class ListOfferings extends ListRecords
11 11
 {
12
-    protected static string $resource = TaxResource::class;
12
+    protected static string $resource = OfferingResource::class;
13 13
 
14 14
     protected function getHeaderActions(): array
15 15
     {
@@ -20,6 +20,6 @@ class ListTaxes extends ListRecords
20 20
 
21 21
     public function getMaxContentWidth(): MaxWidth | string | null
22 22
     {
23
-        return MaxWidth::ScreenTwoExtraLarge;
23
+        return 'max-w-8xl';
24 24
     }
25 25
 }

+ 579
- 0
app/Filament/Company/Resources/Purchases/BillResource.php View File

@@ -0,0 +1,579 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases;
4
+
5
+use App\Enums\Accounting\BillStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Filament\Company\Resources\Purchases\BillResource\Pages;
8
+use App\Filament\Tables\Actions\ReplicateBulkAction;
9
+use App\Filament\Tables\Filters\DateRangeFilter;
10
+use App\Models\Accounting\Adjustment;
11
+use App\Models\Accounting\Bill;
12
+use App\Models\Accounting\DocumentLineItem;
13
+use App\Models\Banking\BankAccount;
14
+use App\Models\Common\Offering;
15
+use App\Utilities\Currency\CurrencyConverter;
16
+use Awcodes\TableRepeater\Components\TableRepeater;
17
+use Awcodes\TableRepeater\Header;
18
+use Closure;
19
+use Filament\Forms;
20
+use Filament\Forms\Form;
21
+use Filament\Notifications\Notification;
22
+use Filament\Resources\Resource;
23
+use Filament\Support\Enums\Alignment;
24
+use Filament\Support\Enums\MaxWidth;
25
+use Filament\Tables;
26
+use Filament\Tables\Table;
27
+use Illuminate\Database\Eloquent\Builder;
28
+use Illuminate\Database\Eloquent\Collection;
29
+use Illuminate\Database\Eloquent\Model;
30
+use Illuminate\Support\Facades\Auth;
31
+
32
+class BillResource extends Resource
33
+{
34
+    protected static ?string $model = Bill::class;
35
+
36
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
37
+
38
+    public static function form(Form $form): Form
39
+    {
40
+        $company = Auth::user()->currentCompany;
41
+
42
+        return $form
43
+            ->schema([
44
+                Forms\Components\Section::make('Bill Details')
45
+                    ->schema([
46
+                        Forms\Components\Split::make([
47
+                            Forms\Components\Group::make([
48
+                                Forms\Components\Select::make('vendor_id')
49
+                                    ->relationship('vendor', 'name')
50
+                                    ->preload()
51
+                                    ->searchable()
52
+                                    ->required(),
53
+                            ]),
54
+                            Forms\Components\Group::make([
55
+                                Forms\Components\TextInput::make('bill_number')
56
+                                    ->label('Bill Number')
57
+                                    ->default(fn () => Bill::getNextDocumentNumber())
58
+                                    ->required(),
59
+                                Forms\Components\TextInput::make('order_number')
60
+                                    ->label('P.O/S.O Number'),
61
+                                Forms\Components\DatePicker::make('date')
62
+                                    ->label('Bill Date')
63
+                                    ->default(now())
64
+                                    ->disabled(function (?Bill $record) {
65
+                                        return $record?->hasPayments();
66
+                                    })
67
+                                    ->required(),
68
+                                Forms\Components\DatePicker::make('due_date')
69
+                                    ->label('Due Date')
70
+                                    ->default(function () use ($company) {
71
+                                        return now()->addDays($company->defaultBill->payment_terms->getDays());
72
+                                    })
73
+                                    ->required(),
74
+                            ])->grow(true),
75
+                        ])->from('md'),
76
+                        TableRepeater::make('lineItems')
77
+                            ->relationship()
78
+                            ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) {
79
+                                if (! is_array($state)) {
80
+                                    $state = [];
81
+                                }
82
+
83
+                                $relationship = $component->getRelationship();
84
+
85
+                                $existingRecords = $component->getCachedExistingRecords();
86
+
87
+                                $recordsToDelete = [];
88
+
89
+                                foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) {
90
+                                    if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) {
91
+                                        continue;
92
+                                    }
93
+
94
+                                    $recordsToDelete[] = $keyToCheckForDeletion;
95
+                                    $existingRecords->forget("record-{$keyToCheckForDeletion}");
96
+                                }
97
+
98
+                                $relationship
99
+                                    ->whereKey($recordsToDelete)
100
+                                    ->get()
101
+                                    ->each(static fn (Model $record) => $record->delete());
102
+
103
+                                $childComponentContainers = $component->getChildComponentContainers(
104
+                                    withHidden: $component->shouldSaveRelationshipsWhenHidden(),
105
+                                );
106
+
107
+                                $itemOrder = 1;
108
+                                $orderColumn = $component->getOrderColumn();
109
+
110
+                                $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
111
+
112
+                                foreach ($childComponentContainers as $itemKey => $item) {
113
+                                    $itemData = $item->getState(shouldCallHooksBefore: false);
114
+
115
+                                    if ($orderColumn) {
116
+                                        $itemData[$orderColumn] = $itemOrder;
117
+
118
+                                        $itemOrder++;
119
+                                    }
120
+
121
+                                    if ($record = ($existingRecords[$itemKey] ?? null)) {
122
+                                        $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record);
123
+
124
+                                        if ($itemData === null) {
125
+                                            continue;
126
+                                        }
127
+
128
+                                        $translatableContentDriver ?
129
+                                            $translatableContentDriver->updateRecord($record, $itemData) :
130
+                                            $record->fill($itemData)->save();
131
+
132
+                                        continue;
133
+                                    }
134
+
135
+                                    $relatedModel = $component->getRelatedModel();
136
+
137
+                                    $itemData = $component->mutateRelationshipDataBeforeCreate($itemData);
138
+
139
+                                    if ($itemData === null) {
140
+                                        continue;
141
+                                    }
142
+
143
+                                    if ($translatableContentDriver) {
144
+                                        $record = $translatableContentDriver->makeRecord($relatedModel, $itemData);
145
+                                    } else {
146
+                                        $record = new $relatedModel;
147
+                                        $record->fill($itemData);
148
+                                    }
149
+
150
+                                    $record = $relationship->save($record);
151
+                                    $item->model($record)->saveRelationships();
152
+                                    $existingRecords->push($record);
153
+                                }
154
+
155
+                                $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords);
156
+
157
+                                /** @var Bill $bill */
158
+                                $bill = $component->getRecord();
159
+
160
+                                // Recalculate totals for line items
161
+                                $bill->lineItems()->each(function (DocumentLineItem $lineItem) {
162
+                                    $lineItem->updateQuietly([
163
+                                        'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
164
+                                        'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(),
165
+                                    ]);
166
+                                });
167
+
168
+                                $subtotal = $bill->lineItems()->sum('subtotal') / 100;
169
+                                $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
170
+                                $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
171
+                                $grandTotal = $subtotal + $taxTotal - $discountTotal;
172
+
173
+                                $bill->updateQuietly([
174
+                                    'subtotal' => $subtotal,
175
+                                    'tax_total' => $taxTotal,
176
+                                    'discount_total' => $discountTotal,
177
+                                    'total' => $grandTotal,
178
+                                ]);
179
+
180
+                                $bill->refresh();
181
+
182
+                                if (! $bill->initialTransaction) {
183
+                                    $bill->createInitialTransaction();
184
+                                } else {
185
+                                    $bill->updateInitialTransaction();
186
+                                }
187
+                            })
188
+                            ->headers([
189
+                                Header::make('Items')->width('15%'),
190
+                                Header::make('Description')->width('25%'),
191
+                                Header::make('Quantity')->width('10%'),
192
+                                Header::make('Price')->width('10%'),
193
+                                Header::make('Taxes')->width('15%'),
194
+                                Header::make('Discounts')->width('15%'),
195
+                                Header::make('Amount')->width('10%')->align('right'),
196
+                            ])
197
+                            ->schema([
198
+                                Forms\Components\Select::make('offering_id')
199
+                                    ->relationship('purchasableOffering', 'name')
200
+                                    ->preload()
201
+                                    ->searchable()
202
+                                    ->required()
203
+                                    ->live()
204
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
205
+                                        $offeringId = $state;
206
+                                        $offeringRecord = Offering::with(['purchaseTaxes', 'purchaseDiscounts'])->find($offeringId);
207
+
208
+                                        if ($offeringRecord) {
209
+                                            $set('description', $offeringRecord->description);
210
+                                            $set('unit_price', $offeringRecord->price);
211
+                                            $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray());
212
+                                            $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
213
+                                        }
214
+                                    }),
215
+                                Forms\Components\TextInput::make('description'),
216
+                                Forms\Components\TextInput::make('quantity')
217
+                                    ->required()
218
+                                    ->numeric()
219
+                                    ->live()
220
+                                    ->default(1),
221
+                                Forms\Components\TextInput::make('unit_price')
222
+                                    ->hiddenLabel()
223
+                                    ->numeric()
224
+                                    ->live()
225
+                                    ->default(0),
226
+                                Forms\Components\Select::make('purchaseTaxes')
227
+                                    ->relationship('purchaseTaxes', 'name')
228
+                                    ->preload()
229
+                                    ->multiple()
230
+                                    ->live()
231
+                                    ->searchable(),
232
+                                Forms\Components\Select::make('purchaseDiscounts')
233
+                                    ->relationship('purchaseDiscounts', 'name')
234
+                                    ->preload()
235
+                                    ->multiple()
236
+                                    ->live()
237
+                                    ->searchable(),
238
+                                Forms\Components\Placeholder::make('total')
239
+                                    ->hiddenLabel()
240
+                                    ->content(function (Forms\Get $get) {
241
+                                        $quantity = max((float) ($get('quantity') ?? 0), 0);
242
+                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
243
+                                        $purchaseTaxes = $get('purchaseTaxes') ?? [];
244
+                                        $purchaseDiscounts = $get('purchaseDiscounts') ?? [];
245
+
246
+                                        $subtotal = $quantity * $unitPrice;
247
+
248
+                                        // Calculate tax amount based on subtotal
249
+                                        $taxAmount = 0;
250
+                                        if (! empty($purchaseTaxes)) {
251
+                                            $taxRates = Adjustment::whereIn('id', $purchaseTaxes)->pluck('rate');
252
+                                            $taxAmount = collect($taxRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
253
+                                        }
254
+
255
+                                        // Calculate discount amount based on subtotal
256
+                                        $discountAmount = 0;
257
+                                        if (! empty($purchaseDiscounts)) {
258
+                                            $discountRates = Adjustment::whereIn('id', $purchaseDiscounts)->pluck('rate');
259
+                                            $discountAmount = collect($discountRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
260
+                                        }
261
+
262
+                                        // Final total
263
+                                        $total = $subtotal + ($taxAmount - $discountAmount);
264
+
265
+                                        return CurrencyConverter::formatToMoney($total);
266
+                                    }),
267
+                            ]),
268
+                        Forms\Components\Grid::make(6)
269
+                            ->schema([
270
+                                Forms\Components\ViewField::make('totals')
271
+                                    ->columnStart(5)
272
+                                    ->columnSpan(2)
273
+                                    ->view('filament.forms.components.bill-totals'),
274
+                            ]),
275
+                    ]),
276
+            ]);
277
+    }
278
+
279
+    public static function table(Table $table): Table
280
+    {
281
+        return $table
282
+            ->defaultSort('due_date')
283
+            ->columns([
284
+                Tables\Columns\TextColumn::make('status')
285
+                    ->badge()
286
+                    ->searchable(),
287
+                Tables\Columns\TextColumn::make('due_date')
288
+                    ->label('Due')
289
+                    ->asRelativeDay()
290
+                    ->sortable(),
291
+                Tables\Columns\TextColumn::make('date')
292
+                    ->date()
293
+                    ->sortable(),
294
+                Tables\Columns\TextColumn::make('bill_number')
295
+                    ->label('Number')
296
+                    ->searchable()
297
+                    ->sortable(),
298
+                Tables\Columns\TextColumn::make('vendor.name')
299
+                    ->sortable(),
300
+                Tables\Columns\TextColumn::make('total')
301
+                    ->currency()
302
+                    ->sortable(),
303
+                Tables\Columns\TextColumn::make('amount_paid')
304
+                    ->label('Amount Paid')
305
+                    ->currency()
306
+                    ->sortable(),
307
+                Tables\Columns\TextColumn::make('amount_due')
308
+                    ->label('Amount Due')
309
+                    ->currency()
310
+                    ->sortable(),
311
+            ])
312
+            ->filters([
313
+                Tables\Filters\SelectFilter::make('vendor')
314
+                    ->relationship('vendor', 'name')
315
+                    ->searchable()
316
+                    ->preload(),
317
+                Tables\Filters\SelectFilter::make('status')
318
+                    ->options(BillStatus::class)
319
+                    ->native(false),
320
+                Tables\Filters\TernaryFilter::make('has_payments')
321
+                    ->label('Has Payments')
322
+                    ->queries(
323
+                        true: fn (Builder $query) => $query->whereHas('payments'),
324
+                        false: fn (Builder $query) => $query->whereDoesntHave('payments'),
325
+                    ),
326
+                DateRangeFilter::make('date')
327
+                    ->fromLabel('From Date')
328
+                    ->untilLabel('To Date')
329
+                    ->indicatorLabel('Date'),
330
+                DateRangeFilter::make('due_date')
331
+                    ->fromLabel('From Due Date')
332
+                    ->untilLabel('To Due Date')
333
+                    ->indicatorLabel('Due'),
334
+            ])
335
+            ->actions([
336
+                Tables\Actions\ActionGroup::make([
337
+                    Tables\Actions\EditAction::make(),
338
+                    Tables\Actions\ViewAction::make(),
339
+                    Tables\Actions\DeleteAction::make(),
340
+                    Bill::getReplicateAction(Tables\Actions\ReplicateAction::class),
341
+                    Tables\Actions\Action::make('recordPayment')
342
+                        ->label('Record Payment')
343
+                        ->stickyModalHeader()
344
+                        ->stickyModalFooter()
345
+                        ->modalFooterActionsAlignment(Alignment::End)
346
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
347
+                        ->icon('heroicon-o-credit-card')
348
+                        ->visible(function (Bill $record) {
349
+                            return $record->canRecordPayment();
350
+                        })
351
+                        ->mountUsing(function (Bill $record, Form $form) {
352
+                            $form->fill([
353
+                                'posted_at' => now(),
354
+                                'amount' => $record->amount_due,
355
+                            ]);
356
+                        })
357
+                        ->databaseTransaction()
358
+                        ->successNotificationTitle('Payment Recorded')
359
+                        ->form([
360
+                            Forms\Components\DatePicker::make('posted_at')
361
+                                ->label('Date'),
362
+                            Forms\Components\TextInput::make('amount')
363
+                                ->label('Amount')
364
+                                ->required()
365
+                                ->money()
366
+                                ->live(onBlur: true)
367
+                                ->helperText(function (Bill $record, $state) {
368
+                                    if (! CurrencyConverter::isValidAmount($state)) {
369
+                                        return null;
370
+                                    }
371
+
372
+                                    $amountDue = $record->getRawOriginal('amount_due');
373
+                                    $amount = CurrencyConverter::convertToCents($state);
374
+
375
+                                    if ($amount <= 0) {
376
+                                        return 'Please enter a valid positive amount';
377
+                                    }
378
+
379
+                                    $newAmountDue = $amountDue - $amount;
380
+
381
+                                    return match (true) {
382
+                                        $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
383
+                                        $newAmountDue === 0 => 'Bill will be fully paid',
384
+                                        default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
385
+                                    };
386
+                                })
387
+                                ->rules([
388
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
389
+                                        if (! CurrencyConverter::isValidAmount($value)) {
390
+                                            $fail('Please enter a valid amount');
391
+                                        }
392
+                                    },
393
+                                ]),
394
+                            Forms\Components\Select::make('payment_method')
395
+                                ->label('Payment Method')
396
+                                ->required()
397
+                                ->options(PaymentMethod::class),
398
+                            Forms\Components\Select::make('bank_account_id')
399
+                                ->label('Account')
400
+                                ->required()
401
+                                ->options(BankAccount::query()
402
+                                    ->get()
403
+                                    ->pluck('account.name', 'id'))
404
+                                ->searchable(),
405
+                            Forms\Components\Textarea::make('notes')
406
+                                ->label('Notes'),
407
+                        ])
408
+                        ->action(function (Bill $record, Tables\Actions\Action $action, array $data) {
409
+                            $record->recordPayment($data);
410
+
411
+                            $action->success();
412
+                        }),
413
+                ]),
414
+            ])
415
+            ->bulkActions([
416
+                Tables\Actions\BulkActionGroup::make([
417
+                    Tables\Actions\DeleteBulkAction::make(),
418
+                    ReplicateBulkAction::make()
419
+                        ->label('Replicate')
420
+                        ->modalWidth(MaxWidth::Large)
421
+                        ->modalDescription('Replicating bills will also replicate their line items. Are you sure you want to proceed?')
422
+                        ->successNotificationTitle('Bills Replicated Successfully')
423
+                        ->failureNotificationTitle('Failed to Replicate Bills')
424
+                        ->databaseTransaction()
425
+                        ->deselectRecordsAfterCompletion()
426
+                        ->excludeAttributes([
427
+                            'status',
428
+                            'amount_paid',
429
+                            'amount_due',
430
+                            'created_by',
431
+                            'updated_by',
432
+                            'created_at',
433
+                            'updated_at',
434
+                            'bill_number',
435
+                            'date',
436
+                            'due_date',
437
+                            'paid_at',
438
+                        ])
439
+                        ->beforeReplicaSaved(function (Bill $replica) {
440
+                            $replica->status = BillStatus::Unpaid;
441
+                            $replica->bill_number = Bill::getNextDocumentNumber();
442
+                            $replica->date = now();
443
+                            $replica->due_date = now()->addDays($replica->company->defaultBill->payment_terms->getDays());
444
+                        })
445
+                        ->withReplicatedRelationships(['lineItems'])
446
+                        ->withExcludedRelationshipAttributes('lineItems', [
447
+                            'subtotal',
448
+                            'total',
449
+                            'created_by',
450
+                            'updated_by',
451
+                            'created_at',
452
+                            'updated_at',
453
+                        ]),
454
+                    Tables\Actions\BulkAction::make('recordPayments')
455
+                        ->label('Record Payments')
456
+                        ->icon('heroicon-o-credit-card')
457
+                        ->stickyModalHeader()
458
+                        ->stickyModalFooter()
459
+                        ->modalFooterActionsAlignment(Alignment::End)
460
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
461
+                        ->databaseTransaction()
462
+                        ->successNotificationTitle('Payments Recorded')
463
+                        ->failureNotificationTitle('Failed to Record Payments')
464
+                        ->deselectRecordsAfterCompletion()
465
+                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
466
+                            $cantRecordPayments = $records->contains(fn (Bill $bill) => ! $bill->canRecordPayment());
467
+
468
+                            if ($cantRecordPayments) {
469
+                                Notification::make()
470
+                                    ->title('Payment Recording Failed')
471
+                                    ->body('Bills that are either paid or voided cannot be processed through bulk payments. Please adjust your selection and try again.')
472
+                                    ->persistent()
473
+                                    ->danger()
474
+                                    ->send();
475
+
476
+                                $action->cancel(true);
477
+                            }
478
+                        })
479
+                        ->mountUsing(function (Collection $records, Form $form) {
480
+                            $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
481
+
482
+                            $form->fill([
483
+                                'posted_at' => now(),
484
+                                'amount' => CurrencyConverter::convertCentsToFormatSimple($totalAmountDue),
485
+                            ]);
486
+                        })
487
+                        ->form([
488
+                            Forms\Components\DatePicker::make('posted_at')
489
+                                ->label('Date'),
490
+                            Forms\Components\TextInput::make('amount')
491
+                                ->label('Amount')
492
+                                ->required()
493
+                                ->money()
494
+                                ->rules([
495
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
496
+                                        if (! CurrencyConverter::isValidAmount($value)) {
497
+                                            $fail('Please enter a valid amount');
498
+                                        }
499
+                                    },
500
+                                ]),
501
+                            Forms\Components\Select::make('payment_method')
502
+                                ->label('Payment Method')
503
+                                ->required()
504
+                                ->options(PaymentMethod::class),
505
+                            Forms\Components\Select::make('bank_account_id')
506
+                                ->label('Account')
507
+                                ->required()
508
+                                ->options(BankAccount::query()
509
+                                    ->get()
510
+                                    ->pluck('account.name', 'id'))
511
+                                ->searchable(),
512
+                            Forms\Components\Textarea::make('notes')
513
+                                ->label('Notes'),
514
+                        ])
515
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
516
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
517
+                            $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
518
+
519
+                            if ($totalPaymentAmount > $totalAmountDue) {
520
+                                $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
521
+
522
+                                Notification::make()
523
+                                    ->title('Excess Payment Amount')
524
+                                    ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
525
+                                    ->persistent()
526
+                                    ->warning()
527
+                                    ->send();
528
+
529
+                                $action->halt(true);
530
+                            }
531
+                        })
532
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
533
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
534
+                            $remainingAmount = $totalPaymentAmount;
535
+
536
+                            $records->each(function (Bill $record) use (&$remainingAmount, $data) {
537
+                                $amountDue = $record->getRawOriginal('amount_due');
538
+
539
+                                if ($amountDue <= 0 || $remainingAmount <= 0) {
540
+                                    return;
541
+                                }
542
+
543
+                                $paymentAmount = min($amountDue, $remainingAmount);
544
+                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
545
+
546
+                                $record->recordPayment($data);
547
+                                $remainingAmount -= $paymentAmount;
548
+                            });
549
+
550
+                            $action->success();
551
+                        }),
552
+                ]),
553
+            ]);
554
+    }
555
+
556
+    public static function getRelations(): array
557
+    {
558
+        return [
559
+            BillResource\RelationManagers\PaymentsRelationManager::class,
560
+        ];
561
+    }
562
+
563
+    public static function getPages(): array
564
+    {
565
+        return [
566
+            'index' => Pages\ListBills::route('/'),
567
+            'create' => Pages\CreateBill::route('/create'),
568
+            'view' => Pages\ViewBill::route('/{record}'),
569
+            'edit' => Pages\EditBill::route('/{record}/edit'),
570
+        ];
571
+    }
572
+
573
+    public static function getWidgets(): array
574
+    {
575
+        return [
576
+            BillResource\Widgets\BillOverview::class,
577
+        ];
578
+    }
579
+}

+ 20
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php View File

@@ -0,0 +1,20 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\BillResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateBill extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = BillResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::Full;
19
+    }
20
+}

+ 28
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php View File

@@ -0,0 +1,28 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\BillResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditBill extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = BillResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::Full;
27
+    }
28
+}

+ 61
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/ListBills.php View File

@@ -0,0 +1,61 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Enums\Accounting\BillStatus;
6
+use App\Filament\Company\Resources\Purchases\BillResource;
7
+use App\Models\Accounting\Bill;
8
+use Filament\Actions;
9
+use Filament\Pages\Concerns\ExposesTableToWidgets;
10
+use Filament\Resources\Components\Tab;
11
+use Filament\Resources\Pages\ListRecords;
12
+use Filament\Support\Enums\MaxWidth;
13
+use Illuminate\Database\Eloquent\Builder;
14
+
15
+class ListBills extends ListRecords
16
+{
17
+    use ExposesTableToWidgets;
18
+
19
+    protected static string $resource = BillResource::class;
20
+
21
+    protected function getHeaderActions(): array
22
+    {
23
+        return [
24
+            Actions\CreateAction::make(),
25
+        ];
26
+    }
27
+
28
+    protected function getHeaderWidgets(): array
29
+    {
30
+        return [
31
+            BillResource\Widgets\BillOverview::class,
32
+        ];
33
+    }
34
+
35
+    public function getMaxContentWidth(): MaxWidth | string | null
36
+    {
37
+        return 'max-w-8xl';
38
+    }
39
+
40
+    public function getTabs(): array
41
+    {
42
+        return [
43
+            'all' => Tab::make()
44
+                ->label('All'),
45
+
46
+            'outstanding' => Tab::make()
47
+                ->label('Outstanding')
48
+                ->modifyQueryUsing(function (Builder $query) {
49
+                    $query->outstanding();
50
+                })
51
+                ->badge(Bill::outstanding()->count()),
52
+
53
+            'paid' => Tab::make()
54
+                ->label('Paid')
55
+                ->modifyQueryUsing(function (Builder $query) {
56
+                    $query->where('status', BillStatus::Paid);
57
+                })
58
+                ->badge(Bill::where('status', BillStatus::Paid)->count()),
59
+        ];
60
+    }
61
+}

+ 78
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/ViewBill.php View File

@@ -0,0 +1,78 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Purchases\BillResource;
6
+use App\Filament\Company\Resources\Purchases\VendorResource;
7
+use App\Models\Accounting\Bill;
8
+use Filament\Actions;
9
+use Filament\Infolists\Components\Section;
10
+use Filament\Infolists\Components\TextEntry;
11
+use Filament\Infolists\Infolist;
12
+use Filament\Resources\Pages\ViewRecord;
13
+use Filament\Support\Enums\FontWeight;
14
+use Filament\Support\Enums\IconPosition;
15
+use Filament\Support\Enums\IconSize;
16
+
17
+class ViewBill extends ViewRecord
18
+{
19
+    protected static string $resource = BillResource::class;
20
+
21
+    protected $listeners = [
22
+        'refresh' => '$refresh',
23
+    ];
24
+
25
+    protected function getHeaderActions(): array
26
+    {
27
+        return [
28
+            Actions\ActionGroup::make([
29
+                Actions\EditAction::make(),
30
+                Actions\DeleteAction::make(),
31
+                Bill::getReplicateAction(),
32
+            ])
33
+                ->label('Actions')
34
+                ->button()
35
+                ->outlined()
36
+                ->dropdownPlacement('bottom-end')
37
+                ->icon('heroicon-c-chevron-down')
38
+                ->iconSize(IconSize::Small)
39
+                ->iconPosition(IconPosition::After),
40
+        ];
41
+    }
42
+
43
+    public function infolist(Infolist $infolist): Infolist
44
+    {
45
+        return $infolist
46
+            ->schema([
47
+                Section::make('Bill Details')
48
+                    ->columns(4)
49
+                    ->schema([
50
+                        TextEntry::make('bill_number')
51
+                            ->label('Invoice #'),
52
+                        TextEntry::make('status')
53
+                            ->badge(),
54
+                        TextEntry::make('vendor.name')
55
+                            ->label('Vendor')
56
+                            ->color('primary')
57
+                            ->weight(FontWeight::SemiBold)
58
+                            ->url(static fn (Bill $record) => VendorResource::getUrl('edit', ['record' => $record->vendor_id])),
59
+                        TextEntry::make('total')
60
+                            ->label('Total')
61
+                            ->money(),
62
+                        TextEntry::make('amount_due')
63
+                            ->label('Amount Due')
64
+                            ->money(),
65
+                        TextEntry::make('date')
66
+                            ->label('Date')
67
+                            ->date(),
68
+                        TextEntry::make('due_date')
69
+                            ->label('Due')
70
+                            ->asRelativeDay(),
71
+                        TextEntry::make('paid_at')
72
+                            ->label('Paid At')
73
+                            ->placeholder('Not Paid')
74
+                            ->date(),
75
+                    ]),
76
+            ]);
77
+    }
78
+}

+ 182
- 0
app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php View File

@@ -0,0 +1,182 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\RelationManagers;
4
+
5
+use App\Enums\Accounting\PaymentMethod;
6
+use App\Enums\Accounting\TransactionType;
7
+use App\Filament\Company\Resources\Purchases\BillResource\Pages\ViewBill;
8
+use App\Models\Accounting\Bill;
9
+use App\Models\Accounting\Transaction;
10
+use App\Models\Banking\BankAccount;
11
+use App\Utilities\Currency\CurrencyAccessor;
12
+use App\Utilities\Currency\CurrencyConverter;
13
+use Closure;
14
+use Filament\Forms;
15
+use Filament\Forms\Form;
16
+use Filament\Resources\RelationManagers\RelationManager;
17
+use Filament\Support\Colors\Color;
18
+use Filament\Support\Enums\FontWeight;
19
+use Filament\Support\Enums\MaxWidth;
20
+use Filament\Tables;
21
+use Filament\Tables\Table;
22
+use Illuminate\Database\Eloquent\Model;
23
+
24
+class PaymentsRelationManager extends RelationManager
25
+{
26
+    protected static string $relationship = 'payments';
27
+
28
+    protected static ?string $modelLabel = 'Payment';
29
+
30
+    protected $listeners = [
31
+        'refresh' => '$refresh',
32
+    ];
33
+
34
+    public function isReadOnly(): bool
35
+    {
36
+        return false;
37
+    }
38
+
39
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
40
+    {
41
+        return $pageClass === ViewBill::class;
42
+    }
43
+
44
+    public function form(Form $form): Form
45
+    {
46
+        return $form
47
+            ->columns(1)
48
+            ->schema([
49
+                Forms\Components\DatePicker::make('posted_at')
50
+                    ->label('Date'),
51
+                Forms\Components\TextInput::make('amount')
52
+                    ->label('Amount')
53
+                    ->required()
54
+                    ->money()
55
+                    ->live(onBlur: true)
56
+                    ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
57
+                        if (! CurrencyConverter::isValidAmount($state)) {
58
+                            return null;
59
+                        }
60
+
61
+                        /** @var Bill $ownerRecord */
62
+                        $ownerRecord = $livewire->getOwnerRecord();
63
+
64
+                        $amountDue = $ownerRecord->getRawOriginal('amount_due');
65
+
66
+                        $amount = CurrencyConverter::convertToCents($state);
67
+
68
+                        if ($amount <= 0) {
69
+                            return 'Please enter a valid positive amount';
70
+                        }
71
+
72
+                        $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
73
+
74
+                        $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
75
+
76
+                        return match (true) {
77
+                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
78
+                            $newAmountDue === 0 => 'Bill will be fully paid',
79
+                            default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
80
+                        };
81
+                    })
82
+                    ->rules([
83
+                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
84
+                            if (! CurrencyConverter::isValidAmount($value)) {
85
+                                $fail('Please enter a valid amount');
86
+                            }
87
+                        },
88
+                    ]),
89
+                Forms\Components\Select::make('payment_method')
90
+                    ->label('Payment Method')
91
+                    ->required()
92
+                    ->options(PaymentMethod::class),
93
+                Forms\Components\Select::make('bank_account_id')
94
+                    ->label('Account')
95
+                    ->required()
96
+                    ->options(BankAccount::query()
97
+                        ->get()
98
+                        ->pluck('account.name', 'id'))
99
+                    ->searchable(),
100
+                Forms\Components\Textarea::make('notes')
101
+                    ->label('Notes'),
102
+            ]);
103
+    }
104
+
105
+    public function table(Table $table): Table
106
+    {
107
+        return $table
108
+            ->recordTitleAttribute('description')
109
+            ->columns([
110
+                Tables\Columns\TextColumn::make('posted_at')
111
+                    ->label('Date')
112
+                    ->sortable()
113
+                    ->defaultDateFormat(),
114
+                Tables\Columns\TextColumn::make('type')
115
+                    ->label('Type')
116
+                    ->sortable()
117
+                    ->toggleable(isToggledHiddenByDefault: true),
118
+                Tables\Columns\TextColumn::make('description')
119
+                    ->label('Description')
120
+                    ->limit(30)
121
+                    ->toggleable(),
122
+                Tables\Columns\TextColumn::make('bankAccount.account.name')
123
+                    ->label('Account')
124
+                    ->toggleable(),
125
+                Tables\Columns\TextColumn::make('amount')
126
+                    ->label('Amount')
127
+                    ->weight(static fn (Transaction $transaction) => $transaction->reviewed ? null : FontWeight::SemiBold)
128
+                    ->color(
129
+                        static fn (Transaction $transaction) => match ($transaction->type) {
130
+                            TransactionType::Deposit => Color::rgb('rgb(' . Color::Green[700] . ')'),
131
+                            TransactionType::Journal => 'primary',
132
+                            default => null,
133
+                        }
134
+                    )
135
+                    ->sortable()
136
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
137
+            ])
138
+            ->filters([
139
+                //
140
+            ])
141
+            ->headerActions([
142
+                Tables\Actions\CreateAction::make()
143
+                    ->label('Record Payment')
144
+                    ->modalHeading(fn (Tables\Actions\CreateAction $action) => $action->getLabel())
145
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
146
+                    ->visible(function () {
147
+                        return $this->getOwnerRecord()->canRecordPayment();
148
+                    })
149
+                    ->mountUsing(function (Form $form) {
150
+                        $record = $this->getOwnerRecord();
151
+                        $form->fill([
152
+                            'posted_at' => now(),
153
+                            'amount' => $record->amount_due,
154
+                        ]);
155
+                    })
156
+                    ->databaseTransaction()
157
+                    ->successNotificationTitle('Payment Recorded')
158
+                    ->action(function (Tables\Actions\CreateAction $action, array $data) {
159
+                        /** @var Bill $record */
160
+                        $record = $this->getOwnerRecord();
161
+
162
+                        $record->recordPayment($data);
163
+
164
+                        $action->success();
165
+
166
+                        $this->dispatch('refresh');
167
+                    }),
168
+            ])
169
+            ->actions([
170
+                Tables\Actions\EditAction::make()
171
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
172
+                    ->after(fn () => $this->dispatch('refresh')),
173
+                Tables\Actions\DeleteAction::make()
174
+                    ->after(fn () => $this->dispatch('refresh')),
175
+            ])
176
+            ->bulkActions([
177
+                Tables\Actions\BulkActionGroup::make([
178
+                    Tables\Actions\DeleteBulkAction::make(),
179
+                ]),
180
+            ]);
181
+    }
182
+}

+ 69
- 0
app/Filament/Company/Resources/Purchases/BillResource/Widgets/BillOverview.php View File

@@ -0,0 +1,69 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Widgets;
4
+
5
+use App\Enums\Accounting\BillStatus;
6
+use App\Filament\Company\Resources\Purchases\BillResource\Pages\ListBills;
7
+use App\Filament\Widgets\EnhancedStatsOverviewWidget;
8
+use App\Utilities\Currency\CurrencyAccessor;
9
+use App\Utilities\Currency\CurrencyConverter;
10
+use Filament\Widgets\Concerns\InteractsWithPageTable;
11
+use Illuminate\Support\Number;
12
+
13
+class BillOverview extends EnhancedStatsOverviewWidget
14
+{
15
+    use InteractsWithPageTable;
16
+
17
+    protected function getTablePage(): string
18
+    {
19
+        return ListBills::class;
20
+    }
21
+
22
+    protected function getStats(): array
23
+    {
24
+        $unpaidBills = $this->getPageTableQuery()
25
+            ->whereIn('status', [BillStatus::Unpaid, BillStatus::Partial, BillStatus::Overdue]);
26
+
27
+        $amountToPay = $unpaidBills->sum('amount_due');
28
+
29
+        $amountOverdue = $unpaidBills
30
+            ->clone()
31
+            ->where('status', BillStatus::Overdue)
32
+            ->sum('amount_due');
33
+
34
+        $amountDueWithin7Days = $unpaidBills
35
+            ->clone()
36
+            ->whereBetween('due_date', [today(), today()->addWeek()])
37
+            ->sum('amount_due');
38
+
39
+        $averagePaymentTime = $this->getPageTableQuery()
40
+            ->whereNotNull('paid_at')
41
+            ->selectRaw('AVG(TIMESTAMPDIFF(DAY, date, paid_at)) as avg_days')
42
+            ->value('avg_days');
43
+
44
+        $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
45
+
46
+        $lastMonthTotal = $this->getPageTableQuery()
47
+            ->where('status', BillStatus::Paid)
48
+            ->whereBetween('date', [
49
+                today()->subMonth()->startOfMonth(),
50
+                today()->subMonth()->endOfMonth(),
51
+            ])
52
+            ->sum('amount_paid');
53
+
54
+        return [
55
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Total To Pay', CurrencyConverter::formatCentsToMoney($amountToPay))
56
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
57
+                ->description('Includes ' . CurrencyConverter::formatCentsToMoney($amountOverdue) . ' overdue'),
58
+
59
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 7 Days', CurrencyConverter::formatCentsToMoney($amountDueWithin7Days))
60
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
61
+
62
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', $averagePaymentTimeFormatted)
63
+                ->suffix('days'),
64
+
65
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Paid Last Month', CurrencyConverter::formatCentsToMoney($lastMonthTotal))
66
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
67
+        ];
68
+    }
69
+}

+ 220
- 0
app/Filament/Company/Resources/Purchases/VendorResource.php View File

@@ -0,0 +1,220 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases;
4
+
5
+use App\Enums\Common\ContractorType;
6
+use App\Enums\Common\VendorType;
7
+use App\Filament\Company\Resources\Purchases\VendorResource\Pages;
8
+use App\Filament\Forms\Components\CreateCurrencySelect;
9
+use App\Filament\Forms\Components\CustomSection;
10
+use App\Filament\Forms\Components\PhoneBuilder;
11
+use App\Models\Common\Vendor;
12
+use Filament\Forms;
13
+use Filament\Forms\Form;
14
+use Filament\Resources\Resource;
15
+use Filament\Tables;
16
+use Filament\Tables\Table;
17
+
18
+class VendorResource extends Resource
19
+{
20
+    protected static ?string $model = Vendor::class;
21
+
22
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
23
+
24
+    public static function form(Form $form): Form
25
+    {
26
+        return $form
27
+            ->schema([
28
+                Forms\Components\Section::make('General Information')
29
+                    ->schema([
30
+                        Forms\Components\Group::make()
31
+                            ->columns(2)
32
+                            ->schema([
33
+                                Forms\Components\TextInput::make('name')
34
+                                    ->label('Vendor Name')
35
+                                    ->required()
36
+                                    ->maxLength(255),
37
+                                Forms\Components\Radio::make('type')
38
+                                    ->label('Vendor Type')
39
+                                    ->required()
40
+                                    ->live()
41
+                                    ->options(VendorType::class)
42
+                                    ->default(VendorType::Regular)
43
+                                    ->columnSpanFull(),
44
+                                CreateCurrencySelect::make('currency_code')
45
+                                    ->relationship('currency', 'name')
46
+                                    ->nullable()
47
+                                    ->visible(fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Regular),
48
+                                Forms\Components\Select::make('contractor_type')
49
+                                    ->label('Contractor Type')
50
+                                    ->required()
51
+                                    ->live()
52
+                                    ->visible(fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Contractor)
53
+                                    ->options(ContractorType::class),
54
+                                Forms\Components\TextInput::make('ssn')
55
+                                    ->label('Social Security Number')
56
+                                    ->required()
57
+                                    ->live()
58
+                                    ->mask('999-99-9999')
59
+                                    ->stripCharacters('-')
60
+                                    ->maxLength(11)
61
+                                    ->visible(fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Individual)
62
+                                    ->maxLength(255),
63
+                                Forms\Components\TextInput::make('ein')
64
+                                    ->label('Employer Identification Number')
65
+                                    ->required()
66
+                                    ->live()
67
+                                    ->mask('99-9999999')
68
+                                    ->stripCharacters('-')
69
+                                    ->maxLength(10)
70
+                                    ->visible(fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Business)
71
+                                    ->maxLength(255),
72
+                                Forms\Components\TextInput::make('account_number')
73
+                                    ->maxLength(255),
74
+                                Forms\Components\TextInput::make('website')
75
+                                    ->maxLength(255),
76
+                                Forms\Components\Textarea::make('notes')
77
+                                    ->columnSpanFull(),
78
+                            ]),
79
+                        CustomSection::make('Primary Contact')
80
+                            ->relationship('contact')
81
+                            ->contained(false)
82
+                            ->schema([
83
+                                Forms\Components\Hidden::make('is_primary')
84
+                                    ->default(true),
85
+                                Forms\Components\TextInput::make('first_name')
86
+                                    ->label('First Name')
87
+                                    ->required()
88
+                                    ->maxLength(255),
89
+                                Forms\Components\TextInput::make('last_name')
90
+                                    ->label('Last Name')
91
+                                    ->required()
92
+                                    ->maxLength(255),
93
+                                Forms\Components\TextInput::make('email')
94
+                                    ->label('Email')
95
+                                    ->required()
96
+                                    ->email()
97
+                                    ->columnSpanFull()
98
+                                    ->maxLength(255),
99
+                                PhoneBuilder::make('phones')
100
+                                    ->hiddenLabel()
101
+                                    ->blockLabels(false)
102
+                                    ->default([
103
+                                        ['type' => 'primary'],
104
+                                    ])
105
+                                    ->columnSpanFull()
106
+                                    ->blocks([
107
+                                        Forms\Components\Builder\Block::make('primary')
108
+                                            ->schema([
109
+                                                Forms\Components\TextInput::make('number')
110
+                                                    ->label('Phone')
111
+                                                    ->required()
112
+                                                    ->maxLength(15),
113
+                                            ])->maxItems(1),
114
+                                        Forms\Components\Builder\Block::make('mobile')
115
+                                            ->schema([
116
+                                                Forms\Components\TextInput::make('number')
117
+                                                    ->label('Mobile')
118
+                                                    ->required()
119
+                                                    ->maxLength(15),
120
+                                            ])->maxItems(1),
121
+                                        Forms\Components\Builder\Block::make('toll_free')
122
+                                            ->schema([
123
+                                                Forms\Components\TextInput::make('number')
124
+                                                    ->label('Toll Free')
125
+                                                    ->required()
126
+                                                    ->maxLength(15),
127
+                                            ])->maxItems(1),
128
+                                        Forms\Components\Builder\Block::make('fax')
129
+                                            ->schema([
130
+                                                Forms\Components\TextInput::make('number')
131
+                                                    ->label('Fax')
132
+                                                    ->live()
133
+                                                    ->maxLength(15),
134
+                                            ])->maxItems(1),
135
+                                    ])
136
+                                    ->deletable(fn (PhoneBuilder $builder) => $builder->getItemsCount() > 1)
137
+                                    ->reorderable(false)
138
+                                    ->blockNumbers(false)
139
+                                    ->addActionLabel('Add Phone'),
140
+                            ])->columns(),
141
+                    ])->columns(1),
142
+                Forms\Components\Section::make('Address Information')
143
+                    ->relationship('address')
144
+                    ->schema([
145
+                        Forms\Components\Hidden::make('type')
146
+                            ->default('general'),
147
+                        Forms\Components\TextInput::make('address_line_1')
148
+                            ->label('Address Line 1')
149
+                            ->required()
150
+                            ->maxLength(255),
151
+                        Forms\Components\TextInput::make('address_line_2')
152
+                            ->label('Address Line 2')
153
+                            ->maxLength(255),
154
+                        Forms\Components\TextInput::make('city')
155
+                            ->label('City')
156
+                            ->required()
157
+                            ->maxLength(255),
158
+                        Forms\Components\TextInput::make('state')
159
+                            ->label('State')
160
+                            ->required()
161
+                            ->maxLength(255),
162
+                        Forms\Components\TextInput::make('postal_code')
163
+                            ->label('Postal Code / Zip Code')
164
+                            ->required()
165
+                            ->maxLength(255),
166
+                        Forms\Components\TextInput::make('country')
167
+                            ->label('Country')
168
+                            ->required()
169
+                            ->maxLength(255),
170
+                    ])
171
+                    ->columns(2),
172
+            ]);
173
+    }
174
+
175
+    public static function table(Table $table): Table
176
+    {
177
+        return $table
178
+            ->columns([
179
+                Tables\Columns\TextColumn::make('type')
180
+                    ->badge()
181
+                    ->searchable(),
182
+                Tables\Columns\TextColumn::make('name')
183
+                    ->searchable()
184
+                    ->description(fn (Vendor $vendor) => $vendor->contact?->full_name),
185
+                Tables\Columns\TextColumn::make('contact.email')
186
+                    ->label('Email')
187
+                    ->searchable(),
188
+                Tables\Columns\TextColumn::make('primaryContact.phones')
189
+                    ->label('Phone')
190
+                    ->state(fn (Vendor $vendor) => $vendor->contact?->first_available_phone),
191
+            ])
192
+            ->filters([
193
+                //
194
+            ])
195
+            ->actions([
196
+                Tables\Actions\EditAction::make(),
197
+            ])
198
+            ->bulkActions([
199
+                Tables\Actions\BulkActionGroup::make([
200
+                    Tables\Actions\DeleteBulkAction::make(),
201
+                ]),
202
+            ]);
203
+    }
204
+
205
+    public static function getRelations(): array
206
+    {
207
+        return [
208
+            //
209
+        ];
210
+    }
211
+
212
+    public static function getPages(): array
213
+    {
214
+        return [
215
+            'index' => Pages\ListVendors::route('/'),
216
+            'create' => Pages\CreateVendor::route('/create'),
217
+            'edit' => Pages\EditVendor::route('/{record}/edit'),
218
+        ];
219
+    }
220
+}

+ 20
- 0
app/Filament/Company/Resources/Purchases/VendorResource/Pages/CreateVendor.php View File

@@ -0,0 +1,20 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\VendorResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateVendor extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = VendorResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::FiveExtraLarge;
19
+    }
20
+}

+ 28
- 0
app/Filament/Company/Resources/Purchases/VendorResource/Pages/EditVendor.php View File

@@ -0,0 +1,28 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\VendorResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditVendor extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = VendorResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::FiveExtraLarge;
27
+    }
28
+}

+ 25
- 0
app/Filament/Company/Resources/Purchases/VendorResource/Pages/ListVendors.php View File

@@ -0,0 +1,25 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Purchases\VendorResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\ListRecords;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class ListVendors extends ListRecords
11
+{
12
+    protected static string $resource = VendorResource::class;
13
+
14
+    protected function getHeaderActions(): array
15
+    {
16
+        return [
17
+            Actions\CreateAction::make(),
18
+        ];
19
+    }
20
+
21
+    public function getMaxContentWidth(): MaxWidth | string | null
22
+    {
23
+        return 'max-w-8xl';
24
+    }
25
+}

+ 294
- 0
app/Filament/Company/Resources/Sales/ClientResource.php View File

@@ -0,0 +1,294 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales;
4
+
5
+use App\Filament\Company\Resources\Sales\ClientResource\Pages;
6
+use App\Filament\Forms\Components\CreateCurrencySelect;
7
+use App\Filament\Forms\Components\CustomSection;
8
+use App\Filament\Forms\Components\PhoneBuilder;
9
+use App\Models\Common\Client;
10
+use Filament\Forms;
11
+use Filament\Forms\Form;
12
+use Filament\Resources\Resource;
13
+use Filament\Tables;
14
+use Filament\Tables\Table;
15
+
16
+class ClientResource extends Resource
17
+{
18
+    protected static ?string $model = Client::class;
19
+
20
+    public static function form(Form $form): Form
21
+    {
22
+        return $form
23
+            ->schema([
24
+                Forms\Components\Section::make('General Information')
25
+                    ->schema([
26
+                        Forms\Components\Group::make()
27
+                            ->columns()
28
+                            ->schema([
29
+                                Forms\Components\TextInput::make('name')
30
+                                    ->label('Client Name')
31
+                                    ->required()
32
+                                    ->maxLength(255),
33
+                                Forms\Components\TextInput::make('account_number')
34
+                                    ->maxLength(255)
35
+                                    ->columnStart(1),
36
+                                Forms\Components\TextInput::make('website')
37
+                                    ->maxLength(255),
38
+                                Forms\Components\Textarea::make('notes')
39
+                                    ->columnSpanFull(),
40
+                            ]),
41
+                        CustomSection::make('Primary Contact')
42
+                            ->relationship('primaryContact')
43
+                            ->contained(false)
44
+                            ->schema([
45
+                                Forms\Components\Hidden::make('is_primary')
46
+                                    ->default(true),
47
+                                Forms\Components\TextInput::make('first_name')
48
+                                    ->label('First Name')
49
+                                    ->required()
50
+                                    ->maxLength(255),
51
+                                Forms\Components\TextInput::make('last_name')
52
+                                    ->label('Last Name')
53
+                                    ->required()
54
+                                    ->maxLength(255),
55
+                                Forms\Components\TextInput::make('email')
56
+                                    ->label('Email')
57
+                                    ->required()
58
+                                    ->email()
59
+                                    ->columnSpanFull()
60
+                                    ->maxLength(255),
61
+                                PhoneBuilder::make('phones')
62
+                                    ->hiddenLabel()
63
+                                    ->blockLabels(false)
64
+                                    ->default([
65
+                                        ['type' => 'primary'],
66
+                                    ])
67
+                                    ->columnSpanFull()
68
+                                    ->blocks([
69
+                                        Forms\Components\Builder\Block::make('primary')
70
+                                            ->schema([
71
+                                                Forms\Components\TextInput::make('number')
72
+                                                    ->label('Phone')
73
+                                                    ->required()
74
+                                                    ->maxLength(15),
75
+                                            ])->maxItems(1),
76
+                                        Forms\Components\Builder\Block::make('mobile')
77
+                                            ->schema([
78
+                                                Forms\Components\TextInput::make('number')
79
+                                                    ->label('Mobile')
80
+                                                    ->required()
81
+                                                    ->maxLength(15),
82
+                                            ])->maxItems(1),
83
+                                        Forms\Components\Builder\Block::make('toll_free')
84
+                                            ->schema([
85
+                                                Forms\Components\TextInput::make('number')
86
+                                                    ->label('Toll Free')
87
+                                                    ->required()
88
+                                                    ->maxLength(15),
89
+                                            ])->maxItems(1),
90
+                                        Forms\Components\Builder\Block::make('fax')
91
+                                            ->schema([
92
+                                                Forms\Components\TextInput::make('number')
93
+                                                    ->label('Fax')
94
+                                                    ->live()
95
+                                                    ->maxLength(15),
96
+                                            ])->maxItems(1),
97
+                                    ])
98
+                                    ->deletable(fn (PhoneBuilder $builder) => $builder->getItemsCount() > 1)
99
+                                    ->reorderable(false)
100
+                                    ->blockNumbers(false)
101
+                                    ->addActionLabel('Add Phone'),
102
+                            ])->columns(),
103
+                        Forms\Components\Repeater::make('secondaryContacts')
104
+                            ->relationship()
105
+                            ->hiddenLabel()
106
+                            ->extraAttributes([
107
+                                'class' => 'uncontained',
108
+                            ])
109
+                            ->columns()
110
+                            ->defaultItems(0)
111
+                            ->maxItems(3)
112
+                            ->itemLabel(function (Forms\Components\Repeater $component, array $state): ?string {
113
+                                if ($component->getItemsCount() === 1) {
114
+                                    return 'Secondary Contact';
115
+                                }
116
+
117
+                                $firstName = $state['first_name'] ?? null;
118
+                                $lastName = $state['last_name'] ?? null;
119
+
120
+                                if ($firstName && $lastName) {
121
+                                    return "{$firstName} {$lastName}";
122
+                                }
123
+
124
+                                if ($firstName) {
125
+                                    return $firstName;
126
+                                }
127
+
128
+                                return 'Secondary Contact';
129
+                            })
130
+                            ->addActionLabel('Add Contact')
131
+                            ->schema([
132
+                                Forms\Components\TextInput::make('first_name')
133
+                                    ->label('First Name')
134
+                                    ->required()
135
+                                    ->live(onBlur: true)
136
+                                    ->maxLength(255),
137
+                                Forms\Components\TextInput::make('last_name')
138
+                                    ->label('Last Name')
139
+                                    ->required()
140
+                                    ->live(onBlur: true)
141
+                                    ->maxLength(255),
142
+                                Forms\Components\TextInput::make('email')
143
+                                    ->label('Email')
144
+                                    ->required()
145
+                                    ->email()
146
+                                    ->maxLength(255),
147
+                                PhoneBuilder::make('phones')
148
+                                    ->hiddenLabel()
149
+                                    ->blockLabels(false)
150
+                                    ->default([
151
+                                        ['type' => 'primary'],
152
+                                    ])
153
+                                    ->blocks([
154
+                                        Forms\Components\Builder\Block::make('primary')
155
+                                            ->schema([
156
+                                                Forms\Components\TextInput::make('number')
157
+                                                    ->label('Phone')
158
+                                                    ->required()
159
+                                                    ->maxLength(255),
160
+                                            ])->maxItems(1),
161
+                                    ])
162
+                                    ->addable(false)
163
+                                    ->deletable(false)
164
+                                    ->reorderable(false)
165
+                                    ->blockNumbers(false),
166
+                            ]),
167
+                    ])->columns(1),
168
+                Forms\Components\Section::make('Billing')
169
+                    ->schema([
170
+                        CreateCurrencySelect::make('currency_code')
171
+                            ->relationship('currency', 'name'),
172
+                        CustomSection::make('Billing Address')
173
+                            ->relationship('billingAddress')
174
+                            ->contained(false)
175
+                            ->schema([
176
+                                Forms\Components\Hidden::make('type')
177
+                                    ->default('billing'),
178
+                                Forms\Components\TextInput::make('address_line_1')
179
+                                    ->label('Address Line 1')
180
+                                    ->required()
181
+                                    ->maxLength(255),
182
+                                Forms\Components\TextInput::make('address_line_2')
183
+                                    ->label('Address Line 2')
184
+                                    ->maxLength(255),
185
+                                Forms\Components\TextInput::make('city')
186
+                                    ->label('City')
187
+                                    ->required()
188
+                                    ->maxLength(255),
189
+                                Forms\Components\TextInput::make('state')
190
+                                    ->label('State')
191
+                                    ->required()
192
+                                    ->maxLength(255),
193
+                                Forms\Components\TextInput::make('postal_code')
194
+                                    ->label('Postal Code / Zip Code')
195
+                                    ->required()
196
+                                    ->maxLength(255),
197
+                                Forms\Components\TextInput::make('country')
198
+                                    ->label('Country')
199
+                                    ->required()
200
+                                    ->maxLength(255),
201
+                            ])->columns(),
202
+                    ])
203
+                    ->columns(1),
204
+                Forms\Components\Section::make('Shipping')
205
+                    ->relationship('shippingAddress')
206
+                    ->schema([
207
+                        Forms\Components\TextInput::make('recipient')
208
+                            ->label('Recipient')
209
+                            ->required()
210
+                            ->maxLength(255),
211
+                        Forms\Components\Hidden::make('type')
212
+                            ->default('shipping'),
213
+                        Forms\Components\TextInput::make('phone')
214
+                            ->label('Phone')
215
+                            ->required()
216
+                            ->maxLength(255),
217
+                        CustomSection::make('Shipping Address')
218
+                            ->contained(false)
219
+                            ->schema([
220
+                                Forms\Components\TextInput::make('address_line_1')
221
+                                    ->label('Address Line 1')
222
+                                    ->required()
223
+                                    ->maxLength(255),
224
+                                Forms\Components\TextInput::make('address_line_2')
225
+                                    ->label('Address Line 2')
226
+                                    ->maxLength(255),
227
+                                Forms\Components\TextInput::make('city')
228
+                                    ->label('City')
229
+                                    ->required()
230
+                                    ->maxLength(255),
231
+                                Forms\Components\TextInput::make('state')
232
+                                    ->label('State')
233
+                                    ->required()
234
+                                    ->maxLength(255),
235
+                                Forms\Components\TextInput::make('postal_code')
236
+                                    ->label('Postal Code / Zip Code')
237
+                                    ->required()
238
+                                    ->maxLength(255),
239
+                                Forms\Components\TextInput::make('country')
240
+                                    ->label('Country')
241
+                                    ->required()
242
+                                    ->maxLength(255),
243
+                                Forms\Components\Textarea::make('notes')
244
+                                    ->label('Delivery Instructions')
245
+                                    ->maxLength(255)
246
+                                    ->columnSpanFull(),
247
+                            ])->columns(),
248
+                    ])->columns(),
249
+            ]);
250
+    }
251
+
252
+    public static function table(Table $table): Table
253
+    {
254
+        return $table
255
+            ->columns([
256
+                Tables\Columns\TextColumn::make('name')
257
+                    ->searchable()
258
+                    ->description(fn (Client $client) => $client->primaryContact->full_name),
259
+                Tables\Columns\TextColumn::make('primaryContact.email')
260
+                    ->label('Email')
261
+                    ->searchable(),
262
+                Tables\Columns\TextColumn::make('primaryContact.phones')
263
+                    ->label('Phone')
264
+                    ->state(fn (Client $client) => $client->primaryContact->first_available_phone),
265
+            ])
266
+            ->filters([
267
+                //
268
+            ])
269
+            ->actions([
270
+                Tables\Actions\EditAction::make(),
271
+            ])
272
+            ->bulkActions([
273
+                Tables\Actions\BulkActionGroup::make([
274
+                    Tables\Actions\DeleteBulkAction::make(),
275
+                ]),
276
+            ]);
277
+    }
278
+
279
+    public static function getRelations(): array
280
+    {
281
+        return [
282
+            //
283
+        ];
284
+    }
285
+
286
+    public static function getPages(): array
287
+    {
288
+        return [
289
+            'index' => Pages\ListClients::route('/'),
290
+            'create' => Pages\CreateClient::route('/create'),
291
+            'edit' => Pages\EditClient::route('/{record}/edit'),
292
+        ];
293
+    }
294
+}

+ 20
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php View File

@@ -0,0 +1,20 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\ClientResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateClient extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = ClientResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::FiveExtraLarge;
19
+    }
20
+}

+ 28
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/EditClient.php View File

@@ -0,0 +1,28 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\ClientResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditClient extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = ClientResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::FiveExtraLarge;
27
+    }
28
+}

+ 25
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/ListClients.php View File

@@ -0,0 +1,25 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Sales\ClientResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\ListRecords;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class ListClients extends ListRecords
11
+{
12
+    protected static string $resource = ClientResource::class;
13
+
14
+    protected function getHeaderActions(): array
15
+    {
16
+        return [
17
+            Actions\CreateAction::make(),
18
+        ];
19
+    }
20
+
21
+    public function getMaxContentWidth(): MaxWidth | string | null
22
+    {
23
+        return 'max-w-8xl';
24
+    }
25
+}

+ 706
- 0
app/Filament/Company/Resources/Sales/InvoiceResource.php View File

@@ -0,0 +1,706 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales;
4
+
5
+use App\Collections\Accounting\InvoiceCollection;
6
+use App\Enums\Accounting\InvoiceStatus;
7
+use App\Enums\Accounting\PaymentMethod;
8
+use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
9
+use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
10
+use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
11
+use App\Filament\Tables\Actions\ReplicateBulkAction;
12
+use App\Filament\Tables\Filters\DateRangeFilter;
13
+use App\Models\Accounting\Adjustment;
14
+use App\Models\Accounting\DocumentLineItem;
15
+use App\Models\Accounting\Invoice;
16
+use App\Models\Banking\BankAccount;
17
+use App\Models\Common\Offering;
18
+use App\Utilities\Currency\CurrencyConverter;
19
+use Awcodes\TableRepeater\Components\TableRepeater;
20
+use Awcodes\TableRepeater\Header;
21
+use Closure;
22
+use Filament\Forms;
23
+use Filament\Forms\Components\FileUpload;
24
+use Filament\Forms\Form;
25
+use Filament\Notifications\Notification;
26
+use Filament\Resources\Resource;
27
+use Filament\Support\Enums\Alignment;
28
+use Filament\Support\Enums\MaxWidth;
29
+use Filament\Tables;
30
+use Filament\Tables\Table;
31
+use Illuminate\Database\Eloquent\Builder;
32
+use Illuminate\Database\Eloquent\Collection;
33
+use Illuminate\Database\Eloquent\Model;
34
+use Illuminate\Support\Facades\Auth;
35
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
36
+
37
+class InvoiceResource extends Resource
38
+{
39
+    protected static ?string $model = Invoice::class;
40
+
41
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
42
+
43
+    public static function form(Form $form): Form
44
+    {
45
+        $company = Auth::user()->currentCompany;
46
+
47
+        return $form
48
+            ->schema([
49
+                Forms\Components\Section::make('Invoice Header')
50
+                    ->collapsible()
51
+                    ->schema([
52
+                        Forms\Components\Split::make([
53
+                            Forms\Components\Group::make([
54
+                                FileUpload::make('logo')
55
+                                    ->openable()
56
+                                    ->maxSize(1024)
57
+                                    ->localizeLabel()
58
+                                    ->visibility('public')
59
+                                    ->disk('public')
60
+                                    ->directory('logos/document')
61
+                                    ->imageResizeMode('contain')
62
+                                    ->imageCropAspectRatio('3:2')
63
+                                    ->panelAspectRatio('3:2')
64
+                                    ->maxWidth(MaxWidth::ExtraSmall)
65
+                                    ->panelLayout('integrated')
66
+                                    ->removeUploadedFileButtonPosition('center bottom')
67
+                                    ->uploadButtonPosition('center bottom')
68
+                                    ->uploadProgressIndicatorPosition('center bottom')
69
+                                    ->getUploadedFileNameForStorageUsing(
70
+                                        static fn (TemporaryUploadedFile $file): string => (string) str($file->getClientOriginalName())
71
+                                            ->prepend(Auth::user()->currentCompany->id . '_'),
72
+                                    )
73
+                                    ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/gif']),
74
+                            ]),
75
+                            Forms\Components\Group::make([
76
+                                Forms\Components\TextInput::make('header')
77
+                                    ->default(fn () => $company->defaultInvoice->header),
78
+                                Forms\Components\TextInput::make('subheader')
79
+                                    ->default(fn () => $company->defaultInvoice->subheader),
80
+                                Forms\Components\View::make('filament.forms.components.company-info')
81
+                                    ->viewData([
82
+                                        'company_name' => $company->name,
83
+                                        'company_address' => $company->profile->address,
84
+                                        'company_city' => $company->profile->city?->name,
85
+                                        'company_state' => $company->profile->state?->name,
86
+                                        'company_zip' => $company->profile->zip_code,
87
+                                        'company_country' => $company->profile->state?->country->name,
88
+                                    ]),
89
+                            ])->grow(true),
90
+                        ])->from('md'),
91
+                    ]),
92
+                Forms\Components\Section::make('Invoice Details')
93
+                    ->schema([
94
+                        Forms\Components\Split::make([
95
+                            Forms\Components\Group::make([
96
+                                Forms\Components\Select::make('client_id')
97
+                                    ->relationship('client', 'name')
98
+                                    ->preload()
99
+                                    ->searchable()
100
+                                    ->required(),
101
+                            ]),
102
+                            Forms\Components\Group::make([
103
+                                Forms\Components\TextInput::make('invoice_number')
104
+                                    ->label('Invoice Number')
105
+                                    ->default(fn () => Invoice::getNextDocumentNumber()),
106
+                                Forms\Components\TextInput::make('order_number')
107
+                                    ->label('P.O/S.O Number'),
108
+                                Forms\Components\DatePicker::make('date')
109
+                                    ->label('Invoice Date')
110
+                                    ->live()
111
+                                    ->default(now())
112
+                                    ->disabled(function (?Invoice $record) {
113
+                                        return $record?->hasPayments();
114
+                                    })
115
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
116
+                                        $date = $state;
117
+                                        $dueDate = $get('due_date');
118
+
119
+                                        if ($date && $dueDate && $date > $dueDate) {
120
+                                            $set('due_date', $date);
121
+                                        }
122
+                                    }),
123
+                                Forms\Components\DatePicker::make('due_date')
124
+                                    ->label('Payment Due')
125
+                                    ->default(function () use ($company) {
126
+                                        return now()->addDays($company->defaultInvoice->payment_terms->getDays());
127
+                                    })
128
+                                    ->minDate(static function (Forms\Get $get) {
129
+                                        return $get('date') ?? now();
130
+                                    }),
131
+                            ])->grow(true),
132
+                        ])->from('md'),
133
+                        TableRepeater::make('lineItems')
134
+                            ->relationship()
135
+                            ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) {
136
+                                if (! is_array($state)) {
137
+                                    $state = [];
138
+                                }
139
+
140
+                                $relationship = $component->getRelationship();
141
+
142
+                                $existingRecords = $component->getCachedExistingRecords();
143
+
144
+                                $recordsToDelete = [];
145
+
146
+                                foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) {
147
+                                    if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) {
148
+                                        continue;
149
+                                    }
150
+
151
+                                    $recordsToDelete[] = $keyToCheckForDeletion;
152
+                                    $existingRecords->forget("record-{$keyToCheckForDeletion}");
153
+                                }
154
+
155
+                                $relationship
156
+                                    ->whereKey($recordsToDelete)
157
+                                    ->get()
158
+                                    ->each(static fn (Model $record) => $record->delete());
159
+
160
+                                $childComponentContainers = $component->getChildComponentContainers(
161
+                                    withHidden: $component->shouldSaveRelationshipsWhenHidden(),
162
+                                );
163
+
164
+                                $itemOrder = 1;
165
+                                $orderColumn = $component->getOrderColumn();
166
+
167
+                                $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
168
+
169
+                                foreach ($childComponentContainers as $itemKey => $item) {
170
+                                    $itemData = $item->getState(shouldCallHooksBefore: false);
171
+
172
+                                    if ($orderColumn) {
173
+                                        $itemData[$orderColumn] = $itemOrder;
174
+
175
+                                        $itemOrder++;
176
+                                    }
177
+
178
+                                    if ($record = ($existingRecords[$itemKey] ?? null)) {
179
+                                        $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record);
180
+
181
+                                        if ($itemData === null) {
182
+                                            continue;
183
+                                        }
184
+
185
+                                        $translatableContentDriver ?
186
+                                            $translatableContentDriver->updateRecord($record, $itemData) :
187
+                                            $record->fill($itemData)->save();
188
+
189
+                                        continue;
190
+                                    }
191
+
192
+                                    $relatedModel = $component->getRelatedModel();
193
+
194
+                                    $itemData = $component->mutateRelationshipDataBeforeCreate($itemData);
195
+
196
+                                    if ($itemData === null) {
197
+                                        continue;
198
+                                    }
199
+
200
+                                    if ($translatableContentDriver) {
201
+                                        $record = $translatableContentDriver->makeRecord($relatedModel, $itemData);
202
+                                    } else {
203
+                                        $record = new $relatedModel;
204
+                                        $record->fill($itemData);
205
+                                    }
206
+
207
+                                    $record = $relationship->save($record);
208
+                                    $item->model($record)->saveRelationships();
209
+                                    $existingRecords->push($record);
210
+                                }
211
+
212
+                                $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords);
213
+
214
+                                /** @var Invoice $invoice */
215
+                                $invoice = $component->getRecord();
216
+
217
+                                // Recalculate totals for line items
218
+                                $invoice->lineItems()->each(function (DocumentLineItem $lineItem) {
219
+                                    $lineItem->updateQuietly([
220
+                                        'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
221
+                                        'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(),
222
+                                    ]);
223
+                                });
224
+
225
+                                $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
226
+                                $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
227
+                                $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
228
+                                $grandTotal = $subtotal + $taxTotal - $discountTotal;
229
+
230
+                                $invoice->updateQuietly([
231
+                                    'subtotal' => $subtotal,
232
+                                    'tax_total' => $taxTotal,
233
+                                    'discount_total' => $discountTotal,
234
+                                    'total' => $grandTotal,
235
+                                ]);
236
+
237
+                                if ($invoice->approved_at && $invoice->approvalTransaction) {
238
+                                    $invoice->updateApprovalTransaction();
239
+                                }
240
+                            })
241
+                            ->headers([
242
+                                Header::make('Items')->width('15%'),
243
+                                Header::make('Description')->width('25%'),
244
+                                Header::make('Quantity')->width('10%'),
245
+                                Header::make('Price')->width('10%'),
246
+                                Header::make('Taxes')->width('15%'),
247
+                                Header::make('Discounts')->width('15%'),
248
+                                Header::make('Amount')->width('10%')->align('right'),
249
+                            ])
250
+                            ->schema([
251
+                                Forms\Components\Select::make('offering_id')
252
+                                    ->relationship('sellableOffering', 'name')
253
+                                    ->preload()
254
+                                    ->searchable()
255
+                                    ->required()
256
+                                    ->live()
257
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
258
+                                        $offeringId = $state;
259
+                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
260
+
261
+                                        if ($offeringRecord) {
262
+                                            $set('description', $offeringRecord->description);
263
+                                            $set('unit_price', $offeringRecord->price);
264
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
265
+                                            $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
266
+                                        }
267
+                                    }),
268
+                                Forms\Components\TextInput::make('description'),
269
+                                Forms\Components\TextInput::make('quantity')
270
+                                    ->required()
271
+                                    ->numeric()
272
+                                    ->live()
273
+                                    ->default(1),
274
+                                Forms\Components\TextInput::make('unit_price')
275
+                                    ->hiddenLabel()
276
+                                    ->numeric()
277
+                                    ->live()
278
+                                    ->default(0),
279
+                                Forms\Components\Select::make('salesTaxes')
280
+                                    ->relationship('salesTaxes', 'name')
281
+                                    ->preload()
282
+                                    ->multiple()
283
+                                    ->live()
284
+                                    ->searchable(),
285
+                                Forms\Components\Select::make('salesDiscounts')
286
+                                    ->relationship('salesDiscounts', 'name')
287
+                                    ->preload()
288
+                                    ->multiple()
289
+                                    ->live()
290
+                                    ->searchable(),
291
+                                Forms\Components\Placeholder::make('total')
292
+                                    ->hiddenLabel()
293
+                                    ->content(function (Forms\Get $get) {
294
+                                        $quantity = max((float) ($get('quantity') ?? 0), 0);
295
+                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
296
+                                        $salesTaxes = $get('salesTaxes') ?? [];
297
+                                        $salesDiscounts = $get('salesDiscounts') ?? [];
298
+
299
+                                        $subtotal = $quantity * $unitPrice;
300
+
301
+                                        // Calculate tax amount based on subtotal
302
+                                        $taxAmount = 0;
303
+                                        if (! empty($salesTaxes)) {
304
+                                            $taxRates = Adjustment::whereIn('id', $salesTaxes)->pluck('rate');
305
+                                            $taxAmount = collect($taxRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
306
+                                        }
307
+
308
+                                        // Calculate discount amount based on subtotal
309
+                                        $discountAmount = 0;
310
+                                        if (! empty($salesDiscounts)) {
311
+                                            $discountRates = Adjustment::whereIn('id', $salesDiscounts)->pluck('rate');
312
+                                            $discountAmount = collect($discountRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
313
+                                        }
314
+
315
+                                        // Final total
316
+                                        $total = $subtotal + ($taxAmount - $discountAmount);
317
+
318
+                                        return CurrencyConverter::formatToMoney($total);
319
+                                    }),
320
+                            ]),
321
+                        Forms\Components\Grid::make(6)
322
+                            ->schema([
323
+                                Forms\Components\ViewField::make('totals')
324
+                                    ->columnStart(5)
325
+                                    ->columnSpan(2)
326
+                                    ->view('filament.forms.components.invoice-totals'),
327
+                            ]),
328
+                        Forms\Components\Textarea::make('terms')
329
+                            ->columnSpanFull(),
330
+                    ]),
331
+                Forms\Components\Section::make('Invoice Footer')
332
+                    ->collapsible()
333
+                    ->schema([
334
+                        Forms\Components\Textarea::make('footer')
335
+                            ->columnSpanFull(),
336
+                    ]),
337
+            ]);
338
+    }
339
+
340
+    public static function table(Table $table): Table
341
+    {
342
+        return $table
343
+            ->defaultSort('due_date')
344
+            ->columns([
345
+                Tables\Columns\TextColumn::make('status')
346
+                    ->badge()
347
+                    ->searchable(),
348
+                Tables\Columns\TextColumn::make('due_date')
349
+                    ->label('Due')
350
+                    ->asRelativeDay()
351
+                    ->sortable(),
352
+                Tables\Columns\TextColumn::make('date')
353
+                    ->date()
354
+                    ->sortable(),
355
+                Tables\Columns\TextColumn::make('invoice_number')
356
+                    ->label('Number')
357
+                    ->searchable()
358
+                    ->sortable(),
359
+                Tables\Columns\TextColumn::make('client.name')
360
+                    ->sortable()
361
+                    ->searchable(),
362
+                Tables\Columns\TextColumn::make('total')
363
+                    ->currency()
364
+                    ->sortable(),
365
+                Tables\Columns\TextColumn::make('amount_paid')
366
+                    ->label('Amount Paid')
367
+                    ->currency()
368
+                    ->sortable(),
369
+                Tables\Columns\TextColumn::make('amount_due')
370
+                    ->label('Amount Due')
371
+                    ->currency()
372
+                    ->sortable(),
373
+            ])
374
+            ->filters([
375
+                Tables\Filters\SelectFilter::make('client')
376
+                    ->relationship('client', 'name')
377
+                    ->searchable()
378
+                    ->preload(),
379
+                Tables\Filters\SelectFilter::make('status')
380
+                    ->options(InvoiceStatus::class)
381
+                    ->native(false),
382
+                Tables\Filters\TernaryFilter::make('has_payments')
383
+                    ->label('Has Payments')
384
+                    ->queries(
385
+                        true: fn (Builder $query) => $query->whereHas('payments'),
386
+                        false: fn (Builder $query) => $query->whereDoesntHave('payments'),
387
+                    ),
388
+                DateRangeFilter::make('date')
389
+                    ->fromLabel('From Date')
390
+                    ->untilLabel('To Date')
391
+                    ->indicatorLabel('Date'),
392
+                DateRangeFilter::make('due_date')
393
+                    ->fromLabel('From Due Date')
394
+                    ->untilLabel('To Due Date')
395
+                    ->indicatorLabel('Due'),
396
+            ])
397
+            ->actions([
398
+                Tables\Actions\ActionGroup::make([
399
+                    Tables\Actions\EditAction::make(),
400
+                    Tables\Actions\ViewAction::make(),
401
+                    Tables\Actions\DeleteAction::make(),
402
+                    Invoice::getReplicateAction(Tables\Actions\ReplicateAction::class),
403
+                    Invoice::getApproveDraftAction(Tables\Actions\Action::class),
404
+                    Invoice::getMarkAsSentAction(Tables\Actions\Action::class),
405
+                    Tables\Actions\Action::make('recordPayment')
406
+                        ->label(fn (Invoice $record) => $record->status === InvoiceStatus::Overpaid ? 'Refund Overpayment' : 'Record Payment')
407
+                        ->stickyModalHeader()
408
+                        ->stickyModalFooter()
409
+                        ->modalFooterActionsAlignment(Alignment::End)
410
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
411
+                        ->icon('heroicon-o-credit-card')
412
+                        ->visible(function (Invoice $record) {
413
+                            return $record->canRecordPayment();
414
+                        })
415
+                        ->mountUsing(function (Invoice $record, Form $form) {
416
+                            $form->fill([
417
+                                'posted_at' => now(),
418
+                                'amount' => $record->status === InvoiceStatus::Overpaid ? ltrim($record->amount_due, '-') : $record->amount_due,
419
+                            ]);
420
+                        })
421
+                        ->databaseTransaction()
422
+                        ->successNotificationTitle('Payment Recorded')
423
+                        ->form([
424
+                            Forms\Components\DatePicker::make('posted_at')
425
+                                ->label('Date'),
426
+                            Forms\Components\TextInput::make('amount')
427
+                                ->label('Amount')
428
+                                ->required()
429
+                                ->money()
430
+                                ->live(onBlur: true)
431
+                                ->helperText(function (Invoice $record, $state) {
432
+                                    if (! CurrencyConverter::isValidAmount($state)) {
433
+                                        return null;
434
+                                    }
435
+
436
+                                    $amountDue = $record->getRawOriginal('amount_due');
437
+
438
+                                    $amount = CurrencyConverter::convertToCents($state);
439
+
440
+                                    if ($amount <= 0) {
441
+                                        return 'Please enter a valid positive amount';
442
+                                    }
443
+
444
+                                    if ($record->status === InvoiceStatus::Overpaid) {
445
+                                        $newAmountDue = $amountDue + $amount;
446
+                                    } else {
447
+                                        $newAmountDue = $amountDue - $amount;
448
+                                    }
449
+
450
+                                    return match (true) {
451
+                                        $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
452
+                                        $newAmountDue === 0 => 'Invoice will be fully paid',
453
+                                        default => 'Invoice will be overpaid by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
454
+                                    };
455
+                                })
456
+                                ->rules([
457
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
458
+                                        if (! CurrencyConverter::isValidAmount($value)) {
459
+                                            $fail('Please enter a valid amount');
460
+                                        }
461
+                                    },
462
+                                ]),
463
+                            Forms\Components\Select::make('payment_method')
464
+                                ->label('Payment Method')
465
+                                ->required()
466
+                                ->options(PaymentMethod::class),
467
+                            Forms\Components\Select::make('bank_account_id')
468
+                                ->label('Account')
469
+                                ->required()
470
+                                ->options(BankAccount::query()
471
+                                    ->get()
472
+                                    ->pluck('account.name', 'id'))
473
+                                ->searchable(),
474
+                            Forms\Components\Textarea::make('notes')
475
+                                ->label('Notes'),
476
+                        ])
477
+                        ->action(function (Invoice $record, Tables\Actions\Action $action, array $data) {
478
+                            $record->recordPayment($data);
479
+
480
+                            $action->success();
481
+                        }),
482
+                ]),
483
+            ])
484
+            ->bulkActions([
485
+                Tables\Actions\BulkActionGroup::make([
486
+                    Tables\Actions\DeleteBulkAction::make(),
487
+                    ReplicateBulkAction::make()
488
+                        ->label('Replicate')
489
+                        ->modalWidth(MaxWidth::Large)
490
+                        ->modalDescription('Replicating invoices will also replicate their line items. Are you sure you want to proceed?')
491
+                        ->successNotificationTitle('Invoices Replicated Successfully')
492
+                        ->failureNotificationTitle('Failed to Replicate Invoices')
493
+                        ->databaseTransaction()
494
+                        ->deselectRecordsAfterCompletion()
495
+                        ->excludeAttributes([
496
+                            'status',
497
+                            'amount_paid',
498
+                            'amount_due',
499
+                            'created_by',
500
+                            'updated_by',
501
+                            'created_at',
502
+                            'updated_at',
503
+                            'invoice_number',
504
+                            'date',
505
+                            'due_date',
506
+                        ])
507
+                        ->beforeReplicaSaved(function (Invoice $replica) {
508
+                            $replica->status = InvoiceStatus::Draft;
509
+                            $replica->invoice_number = Invoice::getNextDocumentNumber();
510
+                            $replica->date = now();
511
+                            $replica->due_date = now()->addDays($replica->company->defaultInvoice->payment_terms->getDays());
512
+                        })
513
+                        ->withReplicatedRelationships(['lineItems'])
514
+                        ->withExcludedRelationshipAttributes('lineItems', [
515
+                            'subtotal',
516
+                            'total',
517
+                            'created_by',
518
+                            'updated_by',
519
+                            'created_at',
520
+                            'updated_at',
521
+                        ]),
522
+                    Tables\Actions\BulkAction::make('approveDrafts')
523
+                        ->label('Approve')
524
+                        ->icon('heroicon-o-check-circle')
525
+                        ->databaseTransaction()
526
+                        ->successNotificationTitle('Invoices Approved')
527
+                        ->failureNotificationTitle('Failed to Approve Invoices')
528
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
529
+                            $containsNonDrafts = $records->contains(fn (Invoice $record) => ! $record->isDraft());
530
+
531
+                            if ($containsNonDrafts) {
532
+                                Notification::make()
533
+                                    ->title('Approval Failed')
534
+                                    ->body('Only draft invoices can be approved. Please adjust your selection and try again.')
535
+                                    ->persistent()
536
+                                    ->danger()
537
+                                    ->send();
538
+
539
+                                $action->cancel(true);
540
+                            }
541
+                        })
542
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
543
+                            $records->each(function (Invoice $record) {
544
+                                $record->approveDraft();
545
+                            });
546
+
547
+                            $action->success();
548
+                        }),
549
+                    Tables\Actions\BulkAction::make('markAsSent')
550
+                        ->label('Mark as Sent')
551
+                        ->icon('heroicon-o-paper-airplane')
552
+                        ->databaseTransaction()
553
+                        ->successNotificationTitle('Invoices Sent')
554
+                        ->failureNotificationTitle('Failed to Mark Invoices as Sent')
555
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
556
+                            $doesntContainUnsent = $records->contains(fn (Invoice $record) => $record->status !== InvoiceStatus::Unsent);
557
+
558
+                            if ($doesntContainUnsent) {
559
+                                Notification::make()
560
+                                    ->title('Sending Failed')
561
+                                    ->body('Only unsent invoices can be marked as sent. Please adjust your selection and try again.')
562
+                                    ->persistent()
563
+                                    ->danger()
564
+                                    ->send();
565
+
566
+                                $action->cancel(true);
567
+                            }
568
+                        })
569
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
570
+                            $records->each(function (Invoice $record) {
571
+                                $record->updateQuietly([
572
+                                    'status' => InvoiceStatus::Sent,
573
+                                ]);
574
+                            });
575
+
576
+                            $action->success();
577
+                        }),
578
+                    Tables\Actions\BulkAction::make('recordPayments')
579
+                        ->label('Record Payments')
580
+                        ->icon('heroicon-o-credit-card')
581
+                        ->stickyModalHeader()
582
+                        ->stickyModalFooter()
583
+                        ->modalFooterActionsAlignment(Alignment::End)
584
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
585
+                        ->databaseTransaction()
586
+                        ->successNotificationTitle('Payments Recorded')
587
+                        ->failureNotificationTitle('Failed to Record Payments')
588
+                        ->deselectRecordsAfterCompletion()
589
+                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
590
+                            $cantRecordPayments = $records->contains(fn (Invoice $record) => ! $record->canBulkRecordPayment());
591
+
592
+                            if ($cantRecordPayments) {
593
+                                Notification::make()
594
+                                    ->title('Payment Recording Failed')
595
+                                    ->body('Invoices that are either draft, paid, overpaid, or voided cannot be processed through bulk payments. Please adjust your selection and try again.')
596
+                                    ->persistent()
597
+                                    ->danger()
598
+                                    ->send();
599
+
600
+                                $action->cancel(true);
601
+                            }
602
+                        })
603
+                        ->mountUsing(function (InvoiceCollection $records, Form $form) {
604
+                            $totalAmountDue = $records->sumMoneyFormattedSimple('amount_due');
605
+
606
+                            $form->fill([
607
+                                'posted_at' => now(),
608
+                                'amount' => $totalAmountDue,
609
+                            ]);
610
+                        })
611
+                        ->form([
612
+                            Forms\Components\DatePicker::make('posted_at')
613
+                                ->label('Date'),
614
+                            Forms\Components\TextInput::make('amount')
615
+                                ->label('Amount')
616
+                                ->required()
617
+                                ->money()
618
+                                ->rules([
619
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
620
+                                        if (! CurrencyConverter::isValidAmount($value)) {
621
+                                            $fail('Please enter a valid amount');
622
+                                        }
623
+                                    },
624
+                                ]),
625
+                            Forms\Components\Select::make('payment_method')
626
+                                ->label('Payment Method')
627
+                                ->required()
628
+                                ->options(PaymentMethod::class),
629
+                            Forms\Components\Select::make('bank_account_id')
630
+                                ->label('Account')
631
+                                ->required()
632
+                                ->options(BankAccount::query()
633
+                                    ->get()
634
+                                    ->pluck('account.name', 'id'))
635
+                                ->searchable(),
636
+                            Forms\Components\Textarea::make('notes')
637
+                                ->label('Notes'),
638
+                        ])
639
+                        ->before(function (InvoiceCollection $records, Tables\Actions\BulkAction $action, array $data) {
640
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
641
+                            $totalAmountDue = $records->sumMoneyInCents('amount_due');
642
+
643
+                            if ($totalPaymentAmount > $totalAmountDue) {
644
+                                $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
645
+
646
+                                Notification::make()
647
+                                    ->title('Excess Payment Amount')
648
+                                    ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
649
+                                    ->persistent()
650
+                                    ->warning()
651
+                                    ->send();
652
+
653
+                                $action->halt(true);
654
+                            }
655
+                        })
656
+                        ->action(function (InvoiceCollection $records, Tables\Actions\BulkAction $action, array $data) {
657
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
658
+
659
+                            $remainingAmount = $totalPaymentAmount;
660
+
661
+                            $records->each(function (Invoice $record) use (&$remainingAmount, $data) {
662
+                                $amountDue = $record->getRawOriginal('amount_due');
663
+
664
+                                if ($amountDue <= 0 || $remainingAmount <= 0) {
665
+                                    return;
666
+                                }
667
+
668
+                                $paymentAmount = min($amountDue, $remainingAmount);
669
+
670
+                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
671
+
672
+                                $record->recordPayment($data);
673
+
674
+                                $remainingAmount -= $paymentAmount;
675
+                            });
676
+
677
+                            $action->success();
678
+                        }),
679
+                ]),
680
+            ]);
681
+    }
682
+
683
+    public static function getRelations(): array
684
+    {
685
+        return [
686
+            RelationManagers\PaymentsRelationManager::class,
687
+        ];
688
+    }
689
+
690
+    public static function getPages(): array
691
+    {
692
+        return [
693
+            'index' => Pages\ListInvoices::route('/'),
694
+            'create' => Pages\CreateInvoice::route('/create'),
695
+            'view' => Pages\ViewInvoice::route('/{record}'),
696
+            'edit' => Pages\EditInvoice::route('/{record}/edit'),
697
+        ];
698
+    }
699
+
700
+    public static function getWidgets(): array
701
+    {
702
+        return [
703
+            Widgets\InvoiceOverview::class,
704
+        ];
705
+    }
706
+}

+ 20
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php View File

@@ -0,0 +1,20 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateInvoice extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = InvoiceResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::Full;
19
+    }
20
+}

+ 28
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php View File

@@ -0,0 +1,28 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditInvoice extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = InvoiceResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::Full;
27
+    }
28
+}

+ 62
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php View File

@@ -0,0 +1,62 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource;
7
+use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
8
+use App\Models\Accounting\Invoice;
9
+use Filament\Actions;
10
+use Filament\Pages\Concerns\ExposesTableToWidgets;
11
+use Filament\Resources\Components\Tab;
12
+use Filament\Resources\Pages\ListRecords;
13
+use Filament\Support\Enums\MaxWidth;
14
+use Illuminate\Database\Eloquent\Builder;
15
+
16
+class ListInvoices extends ListRecords
17
+{
18
+    use ExposesTableToWidgets;
19
+
20
+    protected static string $resource = InvoiceResource::class;
21
+
22
+    protected function getHeaderActions(): array
23
+    {
24
+        return [
25
+            Actions\CreateAction::make(),
26
+        ];
27
+    }
28
+
29
+    protected function getHeaderWidgets(): array
30
+    {
31
+        return [
32
+            Widgets\InvoiceOverview::make(),
33
+        ];
34
+    }
35
+
36
+    public function getMaxContentWidth(): MaxWidth | string | null
37
+    {
38
+        return 'max-w-8xl';
39
+    }
40
+
41
+    public function getTabs(): array
42
+    {
43
+        return [
44
+            'all' => Tab::make()
45
+                ->label('All'),
46
+
47
+            'unpaid' => Tab::make()
48
+                ->label('Unpaid')
49
+                ->modifyQueryUsing(function (Builder $query) {
50
+                    $query->unpaid();
51
+                })
52
+                ->badge(Invoice::unpaid()->count()),
53
+
54
+            'draft' => Tab::make()
55
+                ->label('Draft')
56
+                ->modifyQueryUsing(function (Builder $query) {
57
+                    $query->where('status', InvoiceStatus::Draft);
58
+                })
59
+                ->badge(Invoice::where('status', InvoiceStatus::Draft)->count()),
60
+        ];
61
+    }
62
+}

+ 88
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php View File

@@ -0,0 +1,88 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Sales\ClientResource;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource;
7
+use App\Models\Accounting\Invoice;
8
+use Filament\Actions;
9
+use Filament\Infolists\Components\Section;
10
+use Filament\Infolists\Components\TextEntry;
11
+use Filament\Infolists\Infolist;
12
+use Filament\Resources\Pages\ViewRecord;
13
+use Filament\Support\Enums\FontWeight;
14
+use Filament\Support\Enums\IconPosition;
15
+use Filament\Support\Enums\IconSize;
16
+
17
+class ViewInvoice extends ViewRecord
18
+{
19
+    protected static string $resource = InvoiceResource::class;
20
+
21
+    protected $listeners = [
22
+        'refresh' => '$refresh',
23
+    ];
24
+
25
+    protected function getHeaderActions(): array
26
+    {
27
+        return [
28
+            Actions\ActionGroup::make([
29
+                Actions\EditAction::make(),
30
+                Actions\DeleteAction::make(),
31
+                Invoice::getApproveDraftAction(),
32
+                Invoice::getMarkAsSentAction(),
33
+                Invoice::getReplicateAction(),
34
+            ])
35
+                ->label('Actions')
36
+                ->button()
37
+                ->outlined()
38
+                ->dropdownPlacement('bottom-end')
39
+                ->icon('heroicon-c-chevron-down')
40
+                ->iconSize(IconSize::Small)
41
+                ->iconPosition(IconPosition::After),
42
+        ];
43
+    }
44
+
45
+    public function infolist(Infolist $infolist): Infolist
46
+    {
47
+        return $infolist
48
+            ->schema([
49
+                Section::make('Invoice Details')
50
+                    ->columns(4)
51
+                    ->schema([
52
+                        TextEntry::make('invoice_number')
53
+                            ->label('Invoice #'),
54
+                        TextEntry::make('status')
55
+                            ->badge(),
56
+                        TextEntry::make('client.name')
57
+                            ->label('Client')
58
+                            ->color('primary')
59
+                            ->weight(FontWeight::SemiBold)
60
+                            ->url(static fn (Invoice $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
61
+                        TextEntry::make('total')
62
+                            ->label('Total')
63
+                            ->money(),
64
+                        TextEntry::make('amount_due')
65
+                            ->label('Amount Due')
66
+                            ->money(),
67
+                        TextEntry::make('date')
68
+                            ->label('Date')
69
+                            ->date(),
70
+                        TextEntry::make('due_date')
71
+                            ->label('Due')
72
+                            ->asRelativeDay(),
73
+                        TextEntry::make('approved_at')
74
+                            ->label('Approved At')
75
+                            ->placeholder('Not Approved')
76
+                            ->date(),
77
+                        TextEntry::make('last_sent')
78
+                            ->label('Last Sent')
79
+                            ->placeholder('Never')
80
+                            ->date(),
81
+                        TextEntry::make('paid_at')
82
+                            ->label('Paid At')
83
+                            ->placeholder('Not Paid')
84
+                            ->date(),
85
+                    ]),
86
+            ]);
87
+    }
88
+}

+ 187
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php View File

@@ -0,0 +1,187 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Enums\Accounting\TransactionType;
8
+use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ViewInvoice;
9
+use App\Models\Accounting\Invoice;
10
+use App\Models\Accounting\Transaction;
11
+use App\Models\Banking\BankAccount;
12
+use App\Utilities\Currency\CurrencyAccessor;
13
+use App\Utilities\Currency\CurrencyConverter;
14
+use Closure;
15
+use Filament\Forms;
16
+use Filament\Forms\Form;
17
+use Filament\Resources\RelationManagers\RelationManager;
18
+use Filament\Support\Colors\Color;
19
+use Filament\Support\Enums\FontWeight;
20
+use Filament\Support\Enums\MaxWidth;
21
+use Filament\Tables;
22
+use Filament\Tables\Table;
23
+use Illuminate\Database\Eloquent\Model;
24
+
25
+class PaymentsRelationManager extends RelationManager
26
+{
27
+    protected static string $relationship = 'payments';
28
+
29
+    protected static ?string $modelLabel = 'Payment';
30
+
31
+    protected $listeners = [
32
+        'refresh' => '$refresh',
33
+    ];
34
+
35
+    public function isReadOnly(): bool
36
+    {
37
+        return false;
38
+    }
39
+
40
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
41
+    {
42
+        return $ownerRecord->status !== InvoiceStatus::Draft && $pageClass === ViewInvoice::class;
43
+    }
44
+
45
+    public function form(Form $form): Form
46
+    {
47
+        return $form
48
+            ->columns(1)
49
+            ->schema([
50
+                Forms\Components\DatePicker::make('posted_at')
51
+                    ->label('Date'),
52
+                Forms\Components\TextInput::make('amount')
53
+                    ->label('Amount')
54
+                    ->required()
55
+                    ->money()
56
+                    ->live(onBlur: true)
57
+                    ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
58
+                        if (! CurrencyConverter::isValidAmount($state)) {
59
+                            return null;
60
+                        }
61
+
62
+                        /** @var Invoice $ownerRecord */
63
+                        $ownerRecord = $livewire->getOwnerRecord();
64
+
65
+                        $amountDue = $ownerRecord->getRawOriginal('amount_due');
66
+
67
+                        $amount = CurrencyConverter::convertToCents($state);
68
+
69
+                        if ($amount <= 0) {
70
+                            return 'Please enter a valid positive amount';
71
+                        }
72
+
73
+                        $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
74
+
75
+                        if ($ownerRecord->status === InvoiceStatus::Overpaid) {
76
+                            $newAmountDue = $amountDue + $amount - $currentPaymentAmount;
77
+                        } else {
78
+                            $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
79
+                        }
80
+
81
+                        return match (true) {
82
+                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
83
+                            $newAmountDue === 0 => 'Invoice will be fully paid',
84
+                            default => 'Invoice will be overpaid by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
85
+                        };
86
+                    })
87
+                    ->rules([
88
+                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
89
+                            if (! CurrencyConverter::isValidAmount($value)) {
90
+                                $fail('Please enter a valid amount');
91
+                            }
92
+                        },
93
+                    ]),
94
+                Forms\Components\Select::make('payment_method')
95
+                    ->label('Payment Method')
96
+                    ->required()
97
+                    ->options(PaymentMethod::class),
98
+                Forms\Components\Select::make('bank_account_id')
99
+                    ->label('Account')
100
+                    ->required()
101
+                    ->options(BankAccount::query()
102
+                        ->get()
103
+                        ->pluck('account.name', 'id'))
104
+                    ->searchable(),
105
+                Forms\Components\Textarea::make('notes')
106
+                    ->label('Notes'),
107
+            ]);
108
+    }
109
+
110
+    public function table(Table $table): Table
111
+    {
112
+        return $table
113
+            ->recordTitleAttribute('description')
114
+            ->columns([
115
+                Tables\Columns\TextColumn::make('posted_at')
116
+                    ->label('Date')
117
+                    ->sortable()
118
+                    ->defaultDateFormat(),
119
+                Tables\Columns\TextColumn::make('type')
120
+                    ->label('Type')
121
+                    ->sortable()
122
+                    ->toggleable(isToggledHiddenByDefault: true),
123
+                Tables\Columns\TextColumn::make('description')
124
+                    ->label('Description')
125
+                    ->limit(30)
126
+                    ->toggleable(),
127
+                Tables\Columns\TextColumn::make('bankAccount.account.name')
128
+                    ->label('Account')
129
+                    ->toggleable(),
130
+                Tables\Columns\TextColumn::make('amount')
131
+                    ->label('Amount')
132
+                    ->weight(static fn (Transaction $transaction) => $transaction->reviewed ? null : FontWeight::SemiBold)
133
+                    ->color(
134
+                        static fn (Transaction $transaction) => match ($transaction->type) {
135
+                            TransactionType::Deposit => Color::rgb('rgb(' . Color::Green[700] . ')'),
136
+                            TransactionType::Journal => 'primary',
137
+                            default => null,
138
+                        }
139
+                    )
140
+                    ->sortable()
141
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
142
+            ])
143
+            ->filters([
144
+                //
145
+            ])
146
+            ->headerActions([
147
+                Tables\Actions\CreateAction::make()
148
+                    ->label(fn () => $this->getOwnerRecord()->status === InvoiceStatus::Overpaid ? 'Refund Overpayment' : 'Record Payment')
149
+                    ->modalHeading(fn (Tables\Actions\CreateAction $action) => $action->getLabel())
150
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
151
+                    ->visible(function () {
152
+                        return $this->getOwnerRecord()->canRecordPayment();
153
+                    })
154
+                    ->mountUsing(function (Form $form) {
155
+                        $record = $this->getOwnerRecord();
156
+                        $form->fill([
157
+                            'posted_at' => now(),
158
+                            'amount' => $record->status === InvoiceStatus::Overpaid ? ltrim($record->amount_due, '-') : $record->amount_due,
159
+                        ]);
160
+                    })
161
+                    ->databaseTransaction()
162
+                    ->successNotificationTitle('Payment Recorded')
163
+                    ->action(function (Tables\Actions\CreateAction $action, array $data) {
164
+                        /** @var Invoice $record */
165
+                        $record = $this->getOwnerRecord();
166
+
167
+                        $record->recordPayment($data);
168
+
169
+                        $action->success();
170
+
171
+                        $this->dispatch('refresh');
172
+                    }),
173
+            ])
174
+            ->actions([
175
+                Tables\Actions\EditAction::make()
176
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
177
+                    ->after(fn () => $this->dispatch('refresh')),
178
+                Tables\Actions\DeleteAction::make()
179
+                    ->after(fn () => $this->dispatch('refresh')),
180
+            ])
181
+            ->bulkActions([
182
+                Tables\Actions\BulkActionGroup::make([
183
+                    Tables\Actions\DeleteBulkAction::make(),
184
+                ]),
185
+            ]);
186
+    }
187
+}

+ 69
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Widgets/InvoiceOverview.php View File

@@ -0,0 +1,69 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ListInvoices;
7
+use App\Filament\Widgets\EnhancedStatsOverviewWidget;
8
+use App\Utilities\Currency\CurrencyAccessor;
9
+use App\Utilities\Currency\CurrencyConverter;
10
+use Filament\Widgets\Concerns\InteractsWithPageTable;
11
+use Illuminate\Support\Number;
12
+
13
+class InvoiceOverview extends EnhancedStatsOverviewWidget
14
+{
15
+    use InteractsWithPageTable;
16
+
17
+    protected function getTablePage(): string
18
+    {
19
+        return ListInvoices::class;
20
+    }
21
+
22
+    protected function getStats(): array
23
+    {
24
+        $unpaidInvoices = $this->getPageTableQuery()->unpaid();
25
+
26
+        $amountUnpaid = $unpaidInvoices->sum('amount_due');
27
+
28
+        $amountOverdue = $unpaidInvoices
29
+            ->clone()
30
+            ->where('status', InvoiceStatus::Overdue)
31
+            ->sum('amount_due');
32
+
33
+        $amountDueWithin30Days = $unpaidInvoices
34
+            ->clone()
35
+            ->whereBetween('due_date', [today(), today()->addMonth()])
36
+            ->sum('amount_due');
37
+
38
+        $validInvoices = $this->getPageTableQuery()
39
+            ->whereNotIn('status', [
40
+                InvoiceStatus::Void,
41
+                InvoiceStatus::Draft,
42
+            ]);
43
+
44
+        $totalValidInvoiceAmount = $validInvoices->sum('total');
45
+
46
+        $totalValidInvoiceCount = $validInvoices->count();
47
+
48
+        $averageInvoiceTotal = $totalValidInvoiceCount > 0 ? $totalValidInvoiceAmount / $totalValidInvoiceCount : 0;
49
+
50
+        $averagePaymentTime = $this->getPageTableQuery()
51
+            ->whereNotNull('paid_at')
52
+            ->selectRaw('AVG(TIMESTAMPDIFF(DAY, date, paid_at)) as avg_days')
53
+            ->value('avg_days');
54
+
55
+        $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
56
+
57
+        return [
58
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Total Unpaid', CurrencyConverter::formatCentsToMoney($amountUnpaid))
59
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
60
+                ->description('Includes ' . CurrencyConverter::formatCentsToMoney($amountOverdue) . ' overdue'),
61
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 30 Days', CurrencyConverter::formatCentsToMoney($amountDueWithin30Days))
62
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
63
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', $averagePaymentTimeFormatted)
64
+                ->suffix('days'),
65
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Average Invoice Total', CurrencyConverter::formatCentsToMoney($averageInvoiceTotal))
66
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
67
+        ];
68
+    }
69
+}

+ 13
- 0
app/Filament/Forms/Components/CustomSection.php View File

@@ -0,0 +1,13 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Filament\Forms\Components\Section;
6
+use Filament\Support\Concerns\CanBeContained;
7
+
8
+class CustomSection extends Section
9
+{
10
+    use CanBeContained;
11
+
12
+    protected string $view = 'filament.forms.components.custom-section';
13
+}

+ 165
- 0
app/Filament/Forms/Components/LineItemRepeater.php View File

@@ -0,0 +1,165 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Awcodes\TableRepeater\Components\TableRepeater;
6
+use Closure;
7
+use Filament\Forms\ComponentContainer;
8
+use Filament\Forms\Components\Component;
9
+
10
+class LineItemRepeater extends TableRepeater
11
+{
12
+    protected array | Closure $nestedSchema = [];
13
+
14
+    protected ?string $nestedColumn = null;
15
+
16
+    /**
17
+     * Set nested schema and optionally the column it belongs to.
18
+     *
19
+     * @param  array<Component> | Closure  $components
20
+     */
21
+    public function withNestedSchema(array | Closure $components, ?string $underColumn = null): static
22
+    {
23
+        $this->nestedSchema = $components;
24
+        $this->nestedColumn = $underColumn;
25
+
26
+        return $this;
27
+    }
28
+
29
+    /**
30
+     * Get the nested schema.
31
+     *
32
+     * @return array<Component>
33
+     */
34
+    public function getNestedSchema(): array
35
+    {
36
+        return $this->evaluate($this->nestedSchema);
37
+    }
38
+
39
+    /**
40
+     * Get the column under which the nested schema should be rendered.
41
+     */
42
+    public function getNestedColumn(): ?string
43
+    {
44
+        return $this->nestedColumn;
45
+    }
46
+
47
+    /**
48
+     * Determine if there is a nested schema defined.
49
+     */
50
+    public function hasNestedSchema(): bool
51
+    {
52
+        return ! empty($this->getNestedSchema());
53
+    }
54
+
55
+    public function getNestedSchemaMap(): array
56
+    {
57
+        return collect($this->getNestedSchema())
58
+            ->keyBy(fn ($component) => $component->getKey())
59
+            ->all();
60
+    }
61
+
62
+    /**
63
+     * Get all child components, including nested schema.
64
+     *
65
+     * @return array<Component>
66
+     */
67
+    public function getChildComponents(): array
68
+    {
69
+        $components = parent::getChildComponents();
70
+
71
+        if ($this->hasNestedSchema()) {
72
+            $components = array_merge($components, $this->getNestedSchema());
73
+        }
74
+
75
+        return $components;
76
+    }
77
+
78
+    public function getChildComponentContainers(bool $withHidden = false): array
79
+    {
80
+        if ((! $withHidden) && $this->isHidden()) {
81
+            return [];
82
+        }
83
+
84
+        $relationship = $this->getRelationship();
85
+
86
+        $records = $relationship ? $this->getCachedExistingRecords() : null;
87
+
88
+        $containers = [];
89
+
90
+        foreach ($this->getState() ?? [] as $itemKey => $itemData) {
91
+            $containers[$itemKey] = $this
92
+                ->getChildComponentContainer()
93
+                ->statePath($itemKey)
94
+                ->model($relationship ? $records[$itemKey] ?? $this->getRelatedModel() : null)
95
+                ->inlineLabel(false)
96
+                ->getClone();
97
+        }
98
+
99
+        return $containers;
100
+    }
101
+
102
+    public function getChildComponentContainersWithoutNestedSchema(bool $withHidden = false): array
103
+    {
104
+        if ((! $withHidden) && $this->isHidden()) {
105
+            return [];
106
+        }
107
+
108
+        $relationship = $this->getRelationship();
109
+        $records = $relationship ? $this->getCachedExistingRecords() : null;
110
+
111
+        $containers = [];
112
+
113
+        $childComponentsWithoutNestedSchema = $this->getChildComponentsWithoutNestedSchema();
114
+
115
+        foreach ($this->getState() ?? [] as $itemKey => $itemData) {
116
+            $containers[$itemKey] = ComponentContainer::make($this->getLivewire())
117
+                ->parentComponent($this)
118
+                ->statePath($itemKey)
119
+                ->model($relationship ? $records[$itemKey] ?? $this->getRelatedModel() : null)
120
+                ->components($childComponentsWithoutNestedSchema)
121
+                ->inlineLabel(false)
122
+                ->getClone();
123
+        }
124
+
125
+        return $containers;
126
+    }
127
+
128
+    public function getChildComponentContainer($key = null): ComponentContainer
129
+    {
130
+        if (filled($key) && array_key_exists($key, $containers = $this->getChildComponentContainers())) {
131
+            return $containers[$key];
132
+        }
133
+
134
+        return ComponentContainer::make($this->getLivewire())
135
+            ->parentComponent($this)
136
+            ->components($this->getChildComponents());
137
+    }
138
+
139
+    public function getChildComponentsWithoutNestedSchema(): array
140
+    {
141
+        // Fetch the nested schema components.
142
+        $nestedSchema = $this->getNestedSchema();
143
+
144
+        // Filter out the nested schema components.
145
+        return array_filter($this->getChildComponents(), function ($component) use ($nestedSchema) {
146
+            return ! in_array($component, $nestedSchema, true);
147
+        });
148
+    }
149
+
150
+    public function getNestedComponents(): array
151
+    {
152
+        // Fetch the nested schema components.
153
+        $nestedSchema = $this->getNestedSchema();
154
+
155
+        // Separate and return only the nested schema components.
156
+        return array_filter($this->getChildComponents(), function ($component) use ($nestedSchema) {
157
+            return in_array($component, $nestedSchema, true);
158
+        });
159
+    }
160
+
161
+    public function getView(): string
162
+    {
163
+        return 'filament.forms.components.line-item-repeater';
164
+    }
165
+}

+ 10
- 0
app/Filament/Forms/Components/PhoneBuilder.php View File

@@ -0,0 +1,10 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Filament\Forms\Components\Builder;
6
+
7
+class PhoneBuilder extends Builder
8
+{
9
+    protected string $view = 'filament.forms.components.phone-builder';
10
+}

+ 31
- 5
app/Filament/Tables/Actions/ReplicateBulkAction.php View File

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model;
11 11
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
12 12
 use Illuminate\Database\Eloquent\Relations\HasMany;
13 13
 use Illuminate\Database\Eloquent\Relations\HasOne;
14
+use Illuminate\Database\Eloquent\Relations\MorphMany;
14 15
 
15 16
 class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
16 17
 {
@@ -20,6 +21,8 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
20 21
 
21 22
     protected array $relationshipsToReplicate = [];
22 23
 
24
+    protected array | Closure | null $excludedAttributesPerRelationship = null;
25
+
23 26
     public static function getDefaultName(): ?string
24 27
     {
25 28
         return 'replicate';
@@ -48,8 +51,6 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
48 51
                 $records->each(function (Model $record) {
49 52
                     $this->replica = $record->replicate($this->getExcludedAttributes());
50 53
 
51
-                    $this->replica->fill($record->attributesToArray());
52
-
53 54
                     $this->callBeforeReplicaSaved();
54 55
 
55 56
                     $this->replica->save();
@@ -73,17 +74,30 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
73 74
         foreach ($this->relationshipsToReplicate as $relationship) {
74 75
             $relation = $original->$relationship();
75 76
 
77
+            $excludedAttributes = $this->excludedAttributesPerRelationship[$relationship] ?? [];
78
+
76 79
             if ($relation instanceof BelongsToMany) {
77 80
                 $replica->$relationship()->sync($relation->pluck($relation->getRelated()->getKeyName()));
78 81
             } elseif ($relation instanceof HasMany) {
79
-                $relation->each(function (Model $related) use ($replica, $relationship) {
80
-                    $relatedReplica = $related->replicate($this->getExcludedAttributes());
82
+                $relation->each(function (Model $related) use ($excludedAttributes, $replica, $relationship) {
83
+                    $relatedReplica = $related->replicate($excludedAttributes);
81 84
                     $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
82 85
                     $relatedReplica->save();
83 86
                 });
87
+            } elseif ($relation instanceof MorphMany) {
88
+                $relation->each(function (Model $related) use ($excludedAttributes, $relation, $replica) {
89
+                    $relatedReplica = $related->replicate($excludedAttributes);
90
+                    $relatedReplica->{$relation->getForeignKeyName()} = $replica->getKey();
91
+                    $relatedReplica->{$relation->getMorphType()} = $replica->getMorphClass();
92
+                    $relatedReplica->save();
93
+
94
+                    if (method_exists($related, 'adjustments')) {
95
+                        $relatedReplica->adjustments()->sync($related->adjustments->pluck('id'));
96
+                    }
97
+                });
84 98
             } elseif ($relation instanceof HasOne && $relation->exists()) {
85 99
                 $related = $relation->first();
86
-                $relatedReplica = $related->replicate($this->getExcludedAttributes());
100
+                $relatedReplica = $related->replicate($excludedAttributes);
87 101
                 $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
88 102
                 $relatedReplica->save();
89 103
             }
@@ -97,6 +111,18 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
97 111
         return $this;
98 112
     }
99 113
 
114
+    public function withExcludedRelationshipAttributes(string $relationship, array | Closure | null $attributes): static
115
+    {
116
+        $this->excludedAttributesPerRelationship[$relationship] = $attributes;
117
+
118
+        return $this;
119
+    }
120
+
121
+    public function getExcludedRelationshipAttributes(): ?array
122
+    {
123
+        return $this->evaluate($this->excludedAttributesPerRelationship);
124
+    }
125
+
100 126
     public function afterReplicaSaved(Closure $callback): static
101 127
     {
102 128
         $this->afterReplicaSaved = $callback;

+ 172
- 0
app/Filament/Tables/Filters/DateRangeFilter.php View File

@@ -0,0 +1,172 @@
1
+<?php
2
+
3
+namespace App\Filament\Tables\Filters;
4
+
5
+use Filament\Forms\Components\DatePicker;
6
+use Filament\Forms\Get;
7
+use Filament\Forms\Set;
8
+use Filament\Tables\Filters\Filter;
9
+use Filament\Tables\Filters\Indicator;
10
+use Illuminate\Database\Eloquent\Builder;
11
+use Illuminate\Support\Carbon;
12
+use Illuminate\Support\Str;
13
+
14
+class DateRangeFilter extends Filter
15
+{
16
+    protected string $fromLabel = 'From';
17
+
18
+    protected string $untilLabel = 'Until';
19
+
20
+    protected ?string $indicatorLabel = null;
21
+
22
+    protected ?string $defaultFromDate = null;
23
+
24
+    protected ?string $defaultUntilDate = null;
25
+
26
+    protected ?string $fromColumn = null;
27
+
28
+    protected ?string $untilColumn = null;
29
+
30
+    protected function setUp(): void
31
+    {
32
+        parent::setUp();
33
+
34
+        $this->form([
35
+            DatePicker::make('from')
36
+                ->label(fn () => $this->fromLabel)
37
+                ->live()
38
+                ->default(fn () => $this->defaultFromDate)
39
+                ->maxDate(function (Get $get) {
40
+                    return $get('until');
41
+                })
42
+                ->afterStateUpdated(function (Set $set, $state) {
43
+                    if (! $state) {
44
+                        $set('until', null);
45
+                    }
46
+                }),
47
+            DatePicker::make('until')
48
+                ->label(fn () => $this->untilLabel)
49
+                ->live()
50
+                ->default(fn () => $this->defaultUntilDate)
51
+                ->minDate(function (Get $get) {
52
+                    return $get('from');
53
+                }),
54
+        ]);
55
+
56
+        $this->query(function (Builder $query, array $data): Builder {
57
+            $fromColumn = $this->fromColumn ?? $this->getName();
58
+            $untilColumn = $this->untilColumn ?? $this->getName();
59
+
60
+            $fromDate = filled($data['from'] ?? null)
61
+                ? Carbon::parse($data['from'])
62
+                : null;
63
+
64
+            $untilDate = filled($data['until'] ?? null)
65
+                ? Carbon::parse($data['until'])
66
+                : null;
67
+
68
+            if (! $fromDate && ! $untilDate) {
69
+                return $query;
70
+            }
71
+
72
+            return $this->applyDateFilter($query, $fromDate, $untilDate, $fromColumn, $untilColumn);
73
+        });
74
+
75
+        $this->indicateUsing(function (array $data): array {
76
+            $indicators = [];
77
+
78
+            $fromDateFormatted = filled($data['from'] ?? null)
79
+                ? Carbon::parse($data['from'])->toDefaultDateFormat()
80
+                : null;
81
+
82
+            $untilDateFormatted = filled($data['until'] ?? null)
83
+                ? Carbon::parse($data['until'])->toDefaultDateFormat()
84
+                : null;
85
+
86
+            if ($fromDateFormatted && $untilDateFormatted) {
87
+                $indicators[] = Indicator::make($this->getIndicatorLabel() . ': ' . $fromDateFormatted . ' - ' . $untilDateFormatted);
88
+            } else {
89
+                if ($fromDateFormatted) {
90
+                    $indicators[] = Indicator::make($this->fromLabel . ': ' . $fromDateFormatted)
91
+                        ->removeField('from');
92
+                }
93
+
94
+                if ($untilDateFormatted) {
95
+                    $indicators[] = Indicator::make($this->untilLabel . ': ' . $untilDateFormatted)
96
+                        ->removeField('until');
97
+                }
98
+            }
99
+
100
+            return $indicators;
101
+        });
102
+    }
103
+
104
+    protected function applyDateFilter(Builder $query, ?Carbon $fromDate, ?Carbon $untilDate, string $fromColumn, string $untilColumn): Builder
105
+    {
106
+        return $query
107
+            ->when($fromDate && ! $untilDate, function (Builder $query) use ($fromColumn, $fromDate) {
108
+                return $query->where($fromColumn, '>=', $fromDate);
109
+            })
110
+            ->when($fromDate && $untilDate, function (Builder $query) use ($fromColumn, $fromDate, $untilColumn, $untilDate) {
111
+                return $query->where($fromColumn, '>=', $fromDate)
112
+                    ->where($untilColumn, '<=', $untilDate);
113
+            })
114
+            ->when(! $fromDate && $untilDate, function (Builder $query) use ($untilColumn, $untilDate) {
115
+                return $query->where($untilColumn, '<=', $untilDate);
116
+            });
117
+    }
118
+
119
+    public function fromLabel(string $label): static
120
+    {
121
+        $this->fromLabel = $label;
122
+
123
+        return $this;
124
+    }
125
+
126
+    public function untilLabel(string $label): static
127
+    {
128
+        $this->untilLabel = $label;
129
+
130
+        return $this;
131
+    }
132
+
133
+    public function indicatorLabel(string $label): static
134
+    {
135
+        $this->indicatorLabel = $label;
136
+
137
+        return $this;
138
+    }
139
+
140
+    public function getIndicatorLabel(): string
141
+    {
142
+        return $this->indicatorLabel ?? Str::headline($this->getName());
143
+    }
144
+
145
+    public function defaultFromDate(string $date): static
146
+    {
147
+        $this->defaultFromDate = $date;
148
+
149
+        return $this;
150
+    }
151
+
152
+    public function defaultUntilDate(string $date): static
153
+    {
154
+        $this->defaultUntilDate = $date;
155
+
156
+        return $this;
157
+    }
158
+
159
+    public function fromColumn(string $column): static
160
+    {
161
+        $this->fromColumn = $column;
162
+
163
+        return $this;
164
+    }
165
+
166
+    public function untilColumn(string $column): static
167
+    {
168
+        $this->untilColumn = $column;
169
+
170
+        return $this;
171
+    }
172
+}

+ 12
- 0
app/Filament/Widgets/EnhancedStatsOverviewWidget.php View File

@@ -0,0 +1,12 @@
1
+<?php
2
+
3
+namespace App\Filament\Widgets;
4
+
5
+use Filament\Widgets\StatsOverviewWidget;
6
+
7
+class EnhancedStatsOverviewWidget extends StatsOverviewWidget
8
+{
9
+    protected static ?string $pollingInterval = null;
10
+
11
+    protected static bool $isLazy = false;
12
+}

+ 47
- 0
app/Filament/Widgets/EnhancedStatsOverviewWidget/EnhancedStat.php View File

@@ -0,0 +1,47 @@
1
+<?php
2
+
3
+namespace App\Filament\Widgets\EnhancedStatsOverviewWidget;
4
+
5
+use Closure;
6
+use Filament\Support\Concerns\EvaluatesClosures;
7
+use Filament\Widgets\StatsOverviewWidget\Stat;
8
+use Illuminate\Contracts\Support\Htmlable;
9
+use Illuminate\Contracts\View\View;
10
+
11
+class EnhancedStat extends Stat
12
+{
13
+    use EvaluatesClosures;
14
+
15
+    protected string | Htmlable | Closure | null $prefixLabel = null;
16
+
17
+    protected string | Htmlable | Closure | null $suffixLabel = null;
18
+
19
+    public function prefix(string | Htmlable | Closure | null $label): static
20
+    {
21
+        $this->prefixLabel = $label;
22
+
23
+        return $this;
24
+    }
25
+
26
+    public function suffix(string | Htmlable | Closure | null $label): static
27
+    {
28
+        $this->suffixLabel = $label;
29
+
30
+        return $this;
31
+    }
32
+
33
+    public function getPrefixLabel(): string | Htmlable | null
34
+    {
35
+        return $this->evaluate($this->prefixLabel);
36
+    }
37
+
38
+    public function getSuffixLabel(): string | Htmlable | null
39
+    {
40
+        return $this->evaluate($this->suffixLabel);
41
+    }
42
+
43
+    public function render(): View
44
+    {
45
+        return view('filament.widgets.enhanced-stats-overview-widget.enhanced-stat', $this->data());
46
+    }
47
+}

+ 30
- 0
app/Jobs/ProcessOverdueInvoices.php View File

@@ -0,0 +1,30 @@
1
+<?php
2
+
3
+namespace App\Jobs;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Models\Accounting\Invoice;
7
+use Illuminate\Contracts\Queue\ShouldQueue;
8
+use Illuminate\Foundation\Bus\Dispatchable;
9
+use Illuminate\Foundation\Queue\Queueable;
10
+use Illuminate\Queue\InteractsWithQueue;
11
+use Illuminate\Queue\SerializesModels;
12
+
13
+class ProcessOverdueInvoices implements ShouldQueue
14
+{
15
+    use Dispatchable;
16
+    use InteractsWithQueue;
17
+    use Queueable;
18
+    use SerializesModels;
19
+
20
+    /**
21
+     * Execute the job.
22
+     */
23
+    public function handle(): void
24
+    {
25
+        Invoice::query()
26
+            ->whereIn('status', InvoiceStatus::canBeOverdue())
27
+            ->where('due_date', '<', today())
28
+            ->update(['status' => InvoiceStatus::Overdue]);
29
+    }
30
+}

+ 13
- 35
app/Listeners/ConfigureCompanyDefault.php View File

@@ -2,13 +2,9 @@
2 2
 
3 3
 namespace App\Listeners;
4 4
 
5
-use App\Enums\Setting\DateFormat;
6
-use App\Enums\Setting\Font;
7 5
 use App\Enums\Setting\PrimaryColor;
8
-use App\Enums\Setting\RecordsPerPage;
9
-use App\Enums\Setting\TableSortDirection;
10
-use App\Enums\Setting\WeekStart;
11 6
 use App\Events\CompanyConfigured;
7
+use App\Services\CompanySettingsService;
12 8
 use App\Utilities\Currency\ConfigureCurrencies;
13 9
 use Filament\Facades\Filament;
14 10
 use Filament\Forms\Components\DatePicker;
@@ -16,7 +12,6 @@ use Filament\Forms\Components\Section;
16 12
 use Filament\Forms\Components\Tabs\Tab;
17 13
 use Filament\Resources\Components\Tab as ResourcesTab;
18 14
 use Filament\Support\Facades\FilamentColor;
19
-use Filament\Tables\Table;
20 15
 
21 16
 class ConfigureCompanyDefault
22 17
 {
@@ -26,46 +21,29 @@ class ConfigureCompanyDefault
26 21
     public function handle(CompanyConfigured $event): void
27 22
     {
28 23
         $company = $event->company;
24
+        $companyId = $company->id;
29 25
 
30
-        session([
31
-            'current_company_id' => $company->id,
32
-            'default_language' => $company->locale->language ?? config('transmatic.source_locale'),
33
-            'default_timezone' => $company->locale->timezone ?? config('app.timezone'),
34
-            'default_pagination_page_option' => $company->appearance->records_per_page->value ?? RecordsPerPage::DEFAULT,
35
-            'default_sort' => $company->appearance->table_sort_direction->value ?? TableSortDirection::DEFAULT,
36
-            'default_primary_color' => $company->appearance->primary_color->value ?? PrimaryColor::DEFAULT,
37
-            'default_font' => $company->appearance->font->value ?? Font::DEFAULT,
38
-            'default_date_format' => $company->locale->date_format->value ?? DateFormat::DEFAULT,
39
-            'default_week_start' => $company->locale->week_start->value ?? WeekStart::DEFAULT,
40
-        ]);
41
-
42
-        app()->setLocale(session('default_language'));
43
-        locale_set_default(session('default_language'));
44
-        config(['app.timezone' => session('default_timezone')]);
45
-        date_default_timezone_set(session('default_timezone'));
26
+        session(['current_company_id' => $companyId]);
46 27
 
47
-        $paginationPageOptions = RecordsPerPage::caseValues();
28
+        $settings = CompanySettingsService::getSettings($companyId);
48 29
 
49
-        Table::configureUsing(static function (Table $table) use ($paginationPageOptions): void {
50
-
51
-            $table
52
-                ->paginationPageOptions($paginationPageOptions)
53
-                ->defaultSort(column: 'id', direction: session('default_sort'))
54
-                ->defaultPaginationPageOption(session('default_pagination_page_option'));
55
-        }, isImportant: true);
30
+        app()->setLocale($settings['default_language']);
31
+        locale_set_default($settings['default_language']);
32
+        config(['app.timezone' => $settings['default_timezone']]);
33
+        date_default_timezone_set($settings['default_timezone']);
56 34
 
57 35
         FilamentColor::register([
58
-            'primary' => PrimaryColor::from(session('default_primary_color'))->getColor(),
36
+            'primary' => PrimaryColor::from($settings['default_primary_color'])->getColor(),
59 37
         ]);
60 38
 
61 39
         Filament::getPanel('company')
62
-            ->font(session('default_font'))
40
+            ->font($settings['default_font'])
63 41
             ->brandName($company->name);
64 42
 
65
-        DatePicker::configureUsing(static function (DatePicker $component) {
43
+        DatePicker::configureUsing(static function (DatePicker $component) use ($settings) {
66 44
             $component
67
-                ->displayFormat(session('default_date_format'))
68
-                ->firstDayOfWeek(session('default_week_start'));
45
+                ->displayFormat($settings['default_date_format'])
46
+                ->firstDayOfWeek($settings['default_week_start']);
69 47
         });
70 48
 
71 49
         Tab::configureUsing(static function (Tab $tab) {

+ 0
- 4
app/Listeners/SyncAssociatedModels.php View File

@@ -40,10 +40,6 @@ class SyncAssociatedModels
40 40
 
41 41
         $keyToMethodMap = [
42 42
             'bank_account_id' => 'bankAccount',
43
-            'sales_tax_id' => 'salesTax',
44
-            'purchase_tax_id' => 'purchaseTax',
45
-            'sales_discount_id' => 'salesDiscount',
46
-            'purchase_discount_id' => 'purchaseDiscount',
47 43
         ];
48 44
 
49 45
         foreach ($diff as $key => $value) {

+ 0
- 29
app/Listeners/SyncWithCompanyDefaults.php View File

@@ -2,8 +2,6 @@
2 2
 
3 3
 namespace App\Listeners;
4 4
 
5
-use App\Enums\Setting\DiscountType;
6
-use App\Enums\Setting\TaxType;
7 5
 use App\Events\CompanyDefaultEvent;
8 6
 use App\Models\Setting\CompanyDefault;
9 7
 use Illuminate\Support\Facades\DB;
@@ -48,15 +46,12 @@ class SyncWithCompanyDefaults
48 46
     private function updateCompanyDefaults($model, $companyId): void
49 47
     {
50 48
         $modelName = class_basename($model);
51
-        $type = $model->getAttribute('type');
52 49
 
53 50
         $default = CompanyDefault::firstOrNew([
54 51
             'company_id' => $companyId,
55 52
         ]);
56 53
 
57 54
         match ($modelName) {
58
-            'Discount' => $this->handleDiscount($default, $type, $model->getKey()),
59
-            'Tax' => $this->handleTax($default, $type, $model->getKey()),
60 55
             'Currency' => $default->currency_code = $model->getAttribute('code'),
61 56
             'BankAccount' => $default->bank_account_id = $model->getKey(),
62 57
             default => null,
@@ -64,28 +59,4 @@ class SyncWithCompanyDefaults
64 59
 
65 60
         $default->save();
66 61
     }
67
-
68
-    private function handleDiscount($default, $type, $key): void
69
-    {
70
-        if (! in_array($type, [DiscountType::Sales, DiscountType::Purchase], true)) {
71
-            return;
72
-        }
73
-
74
-        match (true) {
75
-            $type === DiscountType::Sales => $default->sales_discount_id = $key,
76
-            $type === DiscountType::Purchase => $default->purchase_discount_id = $key,
77
-        };
78
-    }
79
-
80
-    private function handleTax($default, $type, $key): void
81
-    {
82
-        if (! in_array($type, [TaxType::Sales, TaxType::Purchase], true)) {
83
-            return;
84
-        }
85
-
86
-        match (true) {
87
-            $type === TaxType::Sales => $default->sales_tax_id = $key,
88
-            $type === TaxType::Purchase => $default->purchase_tax_id = $key,
89
-        };
90
-    }
91 62
 }

+ 18
- 3
app/Models/Accounting/Account.php View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
18 18
 use Illuminate\Database\Eloquent\Model;
19 19
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
20 20
 use Illuminate\Database\Eloquent\Relations\HasMany;
21
+use Illuminate\Database\Eloquent\Relations\HasOne;
21 22
 use Illuminate\Support\Carbon;
22 23
 
23 24
 #[ObservedBy(AccountObserver::class)]
@@ -41,7 +42,6 @@ class Account extends Model
41 42
         'description',
42 43
         'archived',
43 44
         'default',
44
-        'bank_account_id',
45 45
         'created_by',
46 46
         'updated_by',
47 47
     ];
@@ -74,9 +74,14 @@ class Account extends Model
74 74
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
75 75
     }
76 76
 
77
-    public function bankAccount(): BelongsTo
77
+    public function bankAccount(): HasOne
78 78
     {
79
-        return $this->belongsTo(BankAccount::class, 'bank_account_id');
79
+        return $this->hasOne(BankAccount::class, 'account_id');
80
+    }
81
+
82
+    public function adjustment(): HasOne
83
+    {
84
+        return $this->hasOne(Adjustment::class, 'account_id');
80 85
     }
81 86
 
82 87
     public function getLastTransactionDate(): ?string
@@ -118,6 +123,16 @@ class Account extends Model
118 123
         return $this->hasMany(JournalEntry::class, 'account_id');
119 124
     }
120 125
 
126
+    public static function getAccountsReceivableAccount(): self
127
+    {
128
+        return self::where('name', 'Accounts Receivable')->firstOrFail();
129
+    }
130
+
131
+    public static function getAccountsPayableAccount(): self
132
+    {
133
+        return self::where('name', 'Accounts Payable')->firstOrFail();
134
+    }
135
+
121 136
     protected static function newFactory(): Factory
122 137
     {
123 138
         return AccountFactory::new();

+ 98
- 0
app/Models/Accounting/Adjustment.php View File

@@ -0,0 +1,98 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\RateCast;
6
+use App\Concerns\Blamable;
7
+use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\AdjustmentCategory;
9
+use App\Enums\Accounting\AdjustmentComputation;
10
+use App\Enums\Accounting\AdjustmentScope;
11
+use App\Enums\Accounting\AdjustmentType;
12
+use App\Models\Common\Offering;
13
+use App\Observers\AdjustmentObserver;
14
+use Database\Factories\Accounting\AdjustmentFactory;
15
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16
+use Illuminate\Database\Eloquent\Factories\Factory;
17
+use Illuminate\Database\Eloquent\Factories\HasFactory;
18
+use Illuminate\Database\Eloquent\Model;
19
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
20
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
21
+
22
+#[ObservedBy(AdjustmentObserver::class)]
23
+class Adjustment extends Model
24
+{
25
+    use Blamable;
26
+    use CompanyOwned;
27
+    use HasFactory;
28
+
29
+    protected $table = 'adjustments';
30
+
31
+    protected $fillable = [
32
+        'company_id',
33
+        'account_id',
34
+        'name',
35
+        'description',
36
+        'category',
37
+        'type',
38
+        'recoverable',
39
+        'rate',
40
+        'computation',
41
+        'scope',
42
+        'start_date',
43
+        'end_date',
44
+        'created_by',
45
+        'updated_by',
46
+    ];
47
+
48
+    protected $casts = [
49
+        'category' => AdjustmentCategory::class,
50
+        'type' => AdjustmentType::class,
51
+        'recoverable' => 'boolean',
52
+        'rate' => RateCast::class,
53
+        'computation' => AdjustmentComputation::class,
54
+        'scope' => AdjustmentScope::class,
55
+        'start_date' => 'datetime',
56
+        'end_date' => 'datetime',
57
+    ];
58
+
59
+    public function account(): BelongsTo
60
+    {
61
+        return $this->belongsTo(Account::class, 'account_id');
62
+    }
63
+
64
+    public function offerings(): MorphToMany
65
+    {
66
+        return $this->morphedByMany(Offering::class, 'adjustmentable', 'adjustmentables');
67
+    }
68
+
69
+    public function isSalesTax(): bool
70
+    {
71
+        return $this->category->isTax() && $this->type->isSales();
72
+    }
73
+
74
+    public function isNonRecoverablePurchaseTax(): bool
75
+    {
76
+        return $this->category->isTax() && $this->type->isPurchase() && $this->recoverable === false;
77
+    }
78
+
79
+    public function isRecoverablePurchaseTax(): bool
80
+    {
81
+        return $this->category->isTax() && $this->type->isPurchase() && $this->recoverable === true;
82
+    }
83
+
84
+    public function isSalesDiscount(): bool
85
+    {
86
+        return $this->category->isDiscount() && $this->type->isSales();
87
+    }
88
+
89
+    public function isPurchaseDiscount(): bool
90
+    {
91
+        return $this->category->isDiscount() && $this->type->isPurchase();
92
+    }
93
+
94
+    protected static function newFactory(): Factory
95
+    {
96
+        return AdjustmentFactory::new();
97
+    }
98
+}

+ 311
- 0
app/Models/Accounting/Bill.php View File

@@ -0,0 +1,311 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Concerns\Blamable;
7
+use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\BillStatus;
9
+use App\Enums\Accounting\JournalEntryType;
10
+use App\Enums\Accounting\TransactionType;
11
+use App\Filament\Company\Resources\Purchases\BillResource;
12
+use App\Models\Common\Vendor;
13
+use App\Observers\BillObserver;
14
+use Filament\Actions\MountableAction;
15
+use Filament\Actions\ReplicateAction;
16
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
17
+use Illuminate\Database\Eloquent\Builder;
18
+use Illuminate\Database\Eloquent\Casts\Attribute;
19
+use Illuminate\Database\Eloquent\Factories\HasFactory;
20
+use Illuminate\Database\Eloquent\Model;
21
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
22
+use Illuminate\Database\Eloquent\Relations\MorphMany;
23
+use Illuminate\Database\Eloquent\Relations\MorphOne;
24
+use Illuminate\Support\Carbon;
25
+
26
+#[ObservedBy(BillObserver::class)]
27
+class Bill extends Model
28
+{
29
+    use Blamable;
30
+    use CompanyOwned;
31
+    use HasFactory;
32
+
33
+    protected $table = 'bills';
34
+
35
+    protected $fillable = [
36
+        'company_id',
37
+        'vendor_id',
38
+        'bill_number',
39
+        'order_number',
40
+        'date',
41
+        'due_date',
42
+        'paid_at',
43
+        'status',
44
+        'currency_code',
45
+        'subtotal',
46
+        'tax_total',
47
+        'discount_total',
48
+        'total',
49
+        'amount_paid',
50
+        'notes',
51
+        'created_by',
52
+        'updated_by',
53
+    ];
54
+
55
+    protected $casts = [
56
+        'date' => 'date',
57
+        'due_date' => 'date',
58
+        'paid_at' => 'datetime',
59
+        'status' => BillStatus::class,
60
+        'subtotal' => MoneyCast::class,
61
+        'tax_total' => MoneyCast::class,
62
+        'discount_total' => MoneyCast::class,
63
+        'total' => MoneyCast::class,
64
+        'amount_paid' => MoneyCast::class,
65
+        'amount_due' => MoneyCast::class,
66
+    ];
67
+
68
+    public function vendor(): BelongsTo
69
+    {
70
+        return $this->belongsTo(Vendor::class);
71
+    }
72
+
73
+    public function lineItems(): MorphMany
74
+    {
75
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
76
+    }
77
+
78
+    public function transactions(): MorphMany
79
+    {
80
+        return $this->morphMany(Transaction::class, 'transactionable');
81
+    }
82
+
83
+    public function payments(): MorphMany
84
+    {
85
+        return $this->transactions()->where('is_payment', true);
86
+    }
87
+
88
+    public function deposits(): MorphMany
89
+    {
90
+        return $this->transactions()->where('type', TransactionType::Deposit)->where('is_payment', true);
91
+    }
92
+
93
+    public function withdrawals(): MorphMany
94
+    {
95
+        return $this->transactions()->where('type', TransactionType::Withdrawal)->where('is_payment', true);
96
+    }
97
+
98
+    public function initialTransaction(): MorphOne
99
+    {
100
+        return $this->morphOne(Transaction::class, 'transactionable')
101
+            ->where('type', TransactionType::Journal);
102
+    }
103
+
104
+    protected function isCurrentlyOverdue(): Attribute
105
+    {
106
+        return Attribute::get(function () {
107
+            return $this->due_date->isBefore(today()) && $this->canBeOverdue();
108
+        });
109
+    }
110
+
111
+    public function canBeOverdue(): bool
112
+    {
113
+        return in_array($this->status, BillStatus::canBeOverdue());
114
+    }
115
+
116
+    public function canRecordPayment(): bool
117
+    {
118
+        return ! in_array($this->status, [
119
+            BillStatus::Paid,
120
+            BillStatus::Void,
121
+        ]);
122
+    }
123
+
124
+    public function hasPayments(): bool
125
+    {
126
+        return $this->payments->isNotEmpty();
127
+    }
128
+
129
+    public static function getNextDocumentNumber(): string
130
+    {
131
+        $company = auth()->user()->currentCompany;
132
+
133
+        if (! $company) {
134
+            throw new \RuntimeException('No current company is set for the user.');
135
+        }
136
+
137
+        $defaultBillSettings = $company->defaultBill;
138
+
139
+        $numberPrefix = $defaultBillSettings->number_prefix;
140
+        $numberDigits = $defaultBillSettings->number_digits;
141
+
142
+        $latestDocument = static::query()
143
+            ->whereNotNull('bill_number')
144
+            ->latest('bill_number')
145
+            ->first();
146
+
147
+        $lastNumberNumericPart = $latestDocument
148
+            ? (int) substr($latestDocument->bill_number, strlen($numberPrefix))
149
+            : 0;
150
+
151
+        $numberNext = $lastNumberNumericPart + 1;
152
+
153
+        return $defaultBillSettings->getNumberNext(
154
+            padded: true,
155
+            format: true,
156
+            prefix: $numberPrefix,
157
+            digits: $numberDigits,
158
+            next: $numberNext
159
+        );
160
+    }
161
+
162
+    public function hasInitialTransaction(): bool
163
+    {
164
+        return $this->initialTransaction()->exists();
165
+    }
166
+
167
+    public function scopeOutstanding(Builder $query): Builder
168
+    {
169
+        return $query->whereIn('status', [
170
+            BillStatus::Unpaid,
171
+            BillStatus::Partial,
172
+            BillStatus::Overdue,
173
+        ]);
174
+    }
175
+
176
+    public function recordPayment(array $data): void
177
+    {
178
+        $transactionType = TransactionType::Withdrawal;
179
+        $transactionDescription = "Bill #{$this->bill_number}: Payment to {$this->vendor->name}";
180
+
181
+        // Create transaction
182
+        $this->transactions()->create([
183
+            'company_id' => $this->company_id,
184
+            'type' => $transactionType,
185
+            'is_payment' => true,
186
+            'posted_at' => $data['posted_at'],
187
+            'amount' => $data['amount'],
188
+            'payment_method' => $data['payment_method'],
189
+            'bank_account_id' => $data['bank_account_id'],
190
+            'account_id' => Account::getAccountsPayableAccount()->id,
191
+            'description' => $transactionDescription,
192
+            'notes' => $data['notes'] ?? null,
193
+        ]);
194
+    }
195
+
196
+    public function createInitialTransaction(?Carbon $postedAt = null): void
197
+    {
198
+        $postedAt ??= $this->date;
199
+
200
+        $transaction = $this->transactions()->create([
201
+            'company_id' => $this->company_id,
202
+            'type' => TransactionType::Journal,
203
+            'posted_at' => $postedAt,
204
+            'amount' => $this->total,
205
+            'description' => 'Bill Creation for Bill #' . $this->bill_number,
206
+        ]);
207
+
208
+        $baseDescription = "{$this->vendor->name}: Bill #{$this->bill_number}";
209
+
210
+        $transaction->journalEntries()->create([
211
+            'company_id' => $this->company_id,
212
+            'type' => JournalEntryType::Credit,
213
+            'account_id' => Account::getAccountsPayableAccount()->id,
214
+            'amount' => $this->total,
215
+            'description' => $baseDescription,
216
+        ]);
217
+
218
+        foreach ($this->lineItems as $lineItem) {
219
+            $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
220
+
221
+            $transaction->journalEntries()->create([
222
+                'company_id' => $this->company_id,
223
+                'type' => JournalEntryType::Debit,
224
+                'account_id' => $lineItem->offering->expense_account_id,
225
+                'amount' => $lineItem->subtotal,
226
+                'description' => $lineItemDescription,
227
+            ]);
228
+
229
+            foreach ($lineItem->adjustments as $adjustment) {
230
+                if ($adjustment->isNonRecoverablePurchaseTax()) {
231
+                    $transaction->journalEntries()->create([
232
+                        'company_id' => $this->company_id,
233
+                        'type' => JournalEntryType::Debit,
234
+                        'account_id' => $lineItem->offering->expense_account_id,
235
+                        'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
236
+                        'description' => "{$lineItemDescription} ({$adjustment->name})",
237
+                    ]);
238
+                } elseif ($adjustment->account_id) {
239
+                    $transaction->journalEntries()->create([
240
+                        'company_id' => $this->company_id,
241
+                        'type' => $adjustment->category->isDiscount() ? JournalEntryType::Credit : JournalEntryType::Debit,
242
+                        'account_id' => $adjustment->account_id,
243
+                        'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
244
+                        'description' => $lineItemDescription,
245
+                    ]);
246
+                }
247
+            }
248
+        }
249
+    }
250
+
251
+    public function updateInitialTransaction(): void
252
+    {
253
+        $transaction = $this->initialTransaction;
254
+
255
+        if ($transaction) {
256
+            $transaction->delete();
257
+        }
258
+
259
+        $this->createInitialTransaction();
260
+    }
261
+
262
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
263
+    {
264
+        return $action::make()
265
+            ->excludeAttributes([
266
+                'status',
267
+                'amount_paid',
268
+                'amount_due',
269
+                'created_by',
270
+                'updated_by',
271
+                'created_at',
272
+                'updated_at',
273
+                'bill_number',
274
+                'date',
275
+                'due_date',
276
+                'paid_at',
277
+            ])
278
+            ->modal(false)
279
+            ->beforeReplicaSaved(function (self $original, self $replica) {
280
+                $replica->status = BillStatus::Unpaid;
281
+                $replica->bill_number = self::getNextDocumentNumber();
282
+                $replica->date = now();
283
+                $replica->due_date = now()->addDays($original->company->defaultBill->payment_terms->getDays());
284
+            })
285
+            ->databaseTransaction()
286
+            ->after(function (self $original, self $replica) {
287
+                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
288
+                    $replicaLineItem = $lineItem->replicate([
289
+                        'documentable_id',
290
+                        'documentable_type',
291
+                        'subtotal',
292
+                        'total',
293
+                        'created_by',
294
+                        'updated_by',
295
+                        'created_at',
296
+                        'updated_at',
297
+                    ]);
298
+
299
+                    $replicaLineItem->documentable_id = $replica->id;
300
+                    $replicaLineItem->documentable_type = $replica->getMorphClass();
301
+
302
+                    $replicaLineItem->save();
303
+
304
+                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
305
+                });
306
+            })
307
+            ->successRedirectUrl(static function (self $replica) {
308
+                return BillResource::getUrl('edit', ['record' => $replica]);
309
+            });
310
+    }
311
+}

+ 132
- 0
app/Models/Accounting/DocumentLineItem.php View File

@@ -0,0 +1,132 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use Akaunting\Money\Money;
6
+use App\Casts\DocumentMoneyCast;
7
+use App\Casts\MoneyCast;
8
+use App\Concerns\Blamable;
9
+use App\Concerns\CompanyOwned;
10
+use App\Enums\Accounting\AdjustmentCategory;
11
+use App\Enums\Accounting\AdjustmentType;
12
+use App\Models\Common\Offering;
13
+use App\Observers\DocumentLineItemObserver;
14
+use App\Utilities\Currency\CurrencyAccessor;
15
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16
+use Illuminate\Database\Eloquent\Factories\HasFactory;
17
+use Illuminate\Database\Eloquent\Model;
18
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
+use Illuminate\Database\Eloquent\Relations\MorphTo;
20
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
21
+
22
+#[ObservedBy(DocumentLineItemObserver::class)]
23
+class DocumentLineItem extends Model
24
+{
25
+    use Blamable;
26
+    use CompanyOwned;
27
+    use HasFactory;
28
+
29
+    protected $table = 'document_line_items';
30
+
31
+    protected $fillable = [
32
+        'company_id',
33
+        'offering_id',
34
+        'description',
35
+        'quantity',
36
+        'unit_price',
37
+        'tax_total',
38
+        'discount_total',
39
+        'created_by',
40
+        'updated_by',
41
+    ];
42
+
43
+    protected $casts = [
44
+        'unit_price' => MoneyCast::class,
45
+        'subtotal' => DocumentMoneyCast::class,
46
+        'tax_total' => MoneyCast::class,
47
+        'discount_total' => MoneyCast::class,
48
+        'total' => MoneyCast::class,
49
+    ];
50
+
51
+    public function documentable(): MorphTo
52
+    {
53
+        return $this->morphTo();
54
+    }
55
+
56
+    public function offering(): BelongsTo
57
+    {
58
+        return $this->belongsTo(Offering::class);
59
+    }
60
+
61
+    public function sellableOffering(): BelongsTo
62
+    {
63
+        return $this->offering()->where('sellable', true);
64
+    }
65
+
66
+    public function purchasableOffering(): BelongsTo
67
+    {
68
+        return $this->offering()->where('purchasable', true);
69
+    }
70
+
71
+    public function adjustments(): MorphToMany
72
+    {
73
+        return $this->morphToMany(Adjustment::class, 'adjustmentable', 'adjustmentables');
74
+    }
75
+
76
+    public function salesTaxes(): MorphToMany
77
+    {
78
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Sales);
79
+    }
80
+
81
+    public function purchaseTaxes(): MorphToMany
82
+    {
83
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Purchase);
84
+    }
85
+
86
+    public function salesDiscounts(): MorphToMany
87
+    {
88
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Sales);
89
+    }
90
+
91
+    public function purchaseDiscounts(): MorphToMany
92
+    {
93
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Purchase);
94
+    }
95
+
96
+    public function taxes(): MorphToMany
97
+    {
98
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax);
99
+    }
100
+
101
+    public function discounts(): MorphToMany
102
+    {
103
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount);
104
+    }
105
+
106
+    public function calculateTaxTotal(): Money
107
+    {
108
+        $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
109
+
110
+        return $this->taxes->reduce(
111
+            fn (Money $carry, Adjustment $tax) => $carry->add($subtotal->multiply($tax->rate / 100)),
112
+            money(0, CurrencyAccessor::getDefaultCurrency())
113
+        );
114
+    }
115
+
116
+    public function calculateDiscountTotal(): Money
117
+    {
118
+        $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
119
+
120
+        return $this->discounts->reduce(
121
+            fn (Money $carry, Adjustment $discount) => $carry->add($subtotal->multiply($discount->rate / 100)),
122
+            money(0, CurrencyAccessor::getDefaultCurrency())
123
+        );
124
+    }
125
+
126
+    public function calculateAdjustmentTotal(Adjustment $adjustment): Money
127
+    {
128
+        $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
129
+
130
+        return $subtotal->multiply($adjustment->rate / 100);
131
+    }
132
+}

+ 384
- 0
app/Models/Accounting/Invoice.php View File

@@ -0,0 +1,384 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Collections\Accounting\InvoiceCollection;
7
+use App\Concerns\Blamable;
8
+use App\Concerns\CompanyOwned;
9
+use App\Enums\Accounting\InvoiceStatus;
10
+use App\Enums\Accounting\JournalEntryType;
11
+use App\Enums\Accounting\TransactionType;
12
+use App\Filament\Company\Resources\Sales\InvoiceResource;
13
+use App\Models\Common\Client;
14
+use App\Observers\InvoiceObserver;
15
+use Filament\Actions\Action;
16
+use Filament\Actions\MountableAction;
17
+use Filament\Actions\ReplicateAction;
18
+use Illuminate\Database\Eloquent\Attributes\CollectedBy;
19
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
20
+use Illuminate\Database\Eloquent\Builder;
21
+use Illuminate\Database\Eloquent\Casts\Attribute;
22
+use Illuminate\Database\Eloquent\Factories\HasFactory;
23
+use Illuminate\Database\Eloquent\Model;
24
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
25
+use Illuminate\Database\Eloquent\Relations\MorphMany;
26
+use Illuminate\Database\Eloquent\Relations\MorphOne;
27
+use Illuminate\Support\Carbon;
28
+
29
+#[ObservedBy(InvoiceObserver::class)]
30
+#[CollectedBy(InvoiceCollection::class)]
31
+class Invoice extends Model
32
+{
33
+    use Blamable;
34
+    use CompanyOwned;
35
+    use HasFactory;
36
+
37
+    protected $table = 'invoices';
38
+
39
+    protected $fillable = [
40
+        'company_id',
41
+        'client_id',
42
+        'logo',
43
+        'header',
44
+        'subheader',
45
+        'invoice_number',
46
+        'order_number',
47
+        'date',
48
+        'due_date',
49
+        'approved_at',
50
+        'paid_at',
51
+        'last_sent',
52
+        'status',
53
+        'currency_code',
54
+        'subtotal',
55
+        'tax_total',
56
+        'discount_total',
57
+        'total',
58
+        'amount_paid',
59
+        'terms',
60
+        'footer',
61
+        'created_by',
62
+        'updated_by',
63
+    ];
64
+
65
+    protected $casts = [
66
+        'date' => 'date',
67
+        'due_date' => 'date',
68
+        'approved_at' => 'datetime',
69
+        'paid_at' => 'datetime',
70
+        'last_sent' => 'datetime',
71
+        'status' => InvoiceStatus::class,
72
+        'subtotal' => MoneyCast::class,
73
+        'tax_total' => MoneyCast::class,
74
+        'discount_total' => MoneyCast::class,
75
+        'total' => MoneyCast::class,
76
+        'amount_paid' => MoneyCast::class,
77
+        'amount_due' => MoneyCast::class,
78
+    ];
79
+
80
+    public function client(): BelongsTo
81
+    {
82
+        return $this->belongsTo(Client::class);
83
+    }
84
+
85
+    public function lineItems(): MorphMany
86
+    {
87
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
88
+    }
89
+
90
+    public function transactions(): MorphMany
91
+    {
92
+        return $this->morphMany(Transaction::class, 'transactionable');
93
+    }
94
+
95
+    public function payments(): MorphMany
96
+    {
97
+        return $this->transactions()->where('is_payment', true);
98
+    }
99
+
100
+    public function deposits(): MorphMany
101
+    {
102
+        return $this->transactions()->where('type', TransactionType::Deposit)->where('is_payment', true);
103
+    }
104
+
105
+    public function withdrawals(): MorphMany
106
+    {
107
+        return $this->transactions()->where('type', TransactionType::Withdrawal)->where('is_payment', true);
108
+    }
109
+
110
+    public function approvalTransaction(): MorphOne
111
+    {
112
+        return $this->morphOne(Transaction::class, 'transactionable')
113
+            ->where('type', TransactionType::Journal);
114
+    }
115
+
116
+    public function scopeUnpaid(Builder $query): Builder
117
+    {
118
+        return $query->whereNotIn('status', [
119
+            InvoiceStatus::Paid,
120
+            InvoiceStatus::Void,
121
+            InvoiceStatus::Draft,
122
+            InvoiceStatus::Overpaid,
123
+        ]);
124
+    }
125
+
126
+    protected function isCurrentlyOverdue(): Attribute
127
+    {
128
+        return Attribute::get(function () {
129
+            return $this->due_date->isBefore(today()) && $this->canBeOverdue();
130
+        });
131
+    }
132
+
133
+    public function isDraft(): bool
134
+    {
135
+        return $this->status === InvoiceStatus::Draft;
136
+    }
137
+
138
+    public function canRecordPayment(): bool
139
+    {
140
+        return ! in_array($this->status, [
141
+            InvoiceStatus::Draft,
142
+            InvoiceStatus::Paid,
143
+            InvoiceStatus::Void,
144
+        ]);
145
+    }
146
+
147
+    public function canBulkRecordPayment(): bool
148
+    {
149
+        return ! in_array($this->status, [
150
+            InvoiceStatus::Draft,
151
+            InvoiceStatus::Paid,
152
+            InvoiceStatus::Void,
153
+            InvoiceStatus::Overpaid,
154
+        ]);
155
+    }
156
+
157
+    public function canBeOverdue(): bool
158
+    {
159
+        return in_array($this->status, InvoiceStatus::canBeOverdue());
160
+    }
161
+
162
+    public function hasPayments(): bool
163
+    {
164
+        return $this->payments->isNotEmpty();
165
+    }
166
+
167
+    public static function getNextDocumentNumber(): string
168
+    {
169
+        $company = auth()->user()->currentCompany;
170
+
171
+        if (! $company) {
172
+            throw new \RuntimeException('No current company is set for the user.');
173
+        }
174
+
175
+        $defaultInvoiceSettings = $company->defaultInvoice;
176
+
177
+        $numberPrefix = $defaultInvoiceSettings->number_prefix;
178
+        $numberDigits = $defaultInvoiceSettings->number_digits;
179
+
180
+        $latestDocument = static::query()
181
+            ->whereNotNull('invoice_number')
182
+            ->latest('invoice_number')
183
+            ->first();
184
+
185
+        $lastNumberNumericPart = $latestDocument
186
+            ? (int) substr($latestDocument->invoice_number, strlen($numberPrefix))
187
+            : 0;
188
+
189
+        $numberNext = $lastNumberNumericPart + 1;
190
+
191
+        return $defaultInvoiceSettings->getNumberNext(
192
+            padded: true,
193
+            format: true,
194
+            prefix: $numberPrefix,
195
+            digits: $numberDigits,
196
+            next: $numberNext
197
+        );
198
+    }
199
+
200
+    public function recordPayment(array $data): void
201
+    {
202
+        $isRefund = $this->status === InvoiceStatus::Overpaid;
203
+
204
+        if ($isRefund) {
205
+            $transactionType = TransactionType::Withdrawal;
206
+            $transactionDescription = "Invoice #{$this->invoice_number}: Refund to {$this->client->name}";
207
+        } else {
208
+            $transactionType = TransactionType::Deposit;
209
+            $transactionDescription = "Invoice #{$this->invoice_number}: Payment from {$this->client->name}";
210
+        }
211
+
212
+        // Create transaction
213
+        $this->transactions()->create([
214
+            'company_id' => $this->company_id,
215
+            'type' => $transactionType,
216
+            'is_payment' => true,
217
+            'posted_at' => $data['posted_at'],
218
+            'amount' => $data['amount'],
219
+            'payment_method' => $data['payment_method'],
220
+            'bank_account_id' => $data['bank_account_id'],
221
+            'account_id' => Account::getAccountsReceivableAccount()->id,
222
+            'description' => $transactionDescription,
223
+            'notes' => $data['notes'] ?? null,
224
+        ]);
225
+    }
226
+
227
+    public function approveDraft(?Carbon $approvedAt = null): void
228
+    {
229
+        if (! $this->isDraft()) {
230
+            throw new \RuntimeException('Invoice is not in draft status.');
231
+        }
232
+
233
+        $this->createApprovalTransaction();
234
+
235
+        $approvedAt ??= now();
236
+
237
+        $this->update([
238
+            'approved_at' => $approvedAt,
239
+            'status' => InvoiceStatus::Unsent,
240
+        ]);
241
+    }
242
+
243
+    public function createApprovalTransaction(): void
244
+    {
245
+        $transaction = $this->transactions()->create([
246
+            'company_id' => $this->company_id,
247
+            'type' => TransactionType::Journal,
248
+            'posted_at' => $this->date,
249
+            'amount' => $this->total,
250
+            'description' => 'Invoice Approval for Invoice #' . $this->invoice_number,
251
+        ]);
252
+
253
+        $baseDescription = "{$this->client->name}: Invoice #{$this->invoice_number}";
254
+
255
+        $transaction->journalEntries()->create([
256
+            'company_id' => $this->company_id,
257
+            'type' => JournalEntryType::Debit,
258
+            'account_id' => Account::getAccountsReceivableAccount()->id,
259
+            'amount' => $this->total,
260
+            'description' => $baseDescription,
261
+        ]);
262
+
263
+        foreach ($this->lineItems as $lineItem) {
264
+            $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
265
+
266
+            $transaction->journalEntries()->create([
267
+                'company_id' => $this->company_id,
268
+                'type' => JournalEntryType::Credit,
269
+                'account_id' => $lineItem->offering->income_account_id,
270
+                'amount' => $lineItem->subtotal,
271
+                'description' => $lineItemDescription,
272
+            ]);
273
+
274
+            foreach ($lineItem->adjustments as $adjustment) {
275
+                $transaction->journalEntries()->create([
276
+                    'company_id' => $this->company_id,
277
+                    'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
278
+                    'account_id' => $adjustment->account_id,
279
+                    'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
280
+                    'description' => $lineItemDescription,
281
+                ]);
282
+            }
283
+        }
284
+    }
285
+
286
+    public function updateApprovalTransaction(): void
287
+    {
288
+        $transaction = $this->approvalTransaction;
289
+
290
+        if ($transaction) {
291
+            $transaction->delete();
292
+        }
293
+
294
+        $this->createApprovalTransaction();
295
+    }
296
+
297
+    public static function getApproveDraftAction(string $action = Action::class): MountableAction
298
+    {
299
+        return $action::make('approveDraft')
300
+            ->label('Approve')
301
+            ->icon('heroicon-o-check-circle')
302
+            ->visible(function (self $record) {
303
+                return $record->isDraft();
304
+            })
305
+            ->databaseTransaction()
306
+            ->successNotificationTitle('Invoice Approved')
307
+            ->action(function (self $record, MountableAction $action) {
308
+                $record->approveDraft();
309
+
310
+                $action->success();
311
+            });
312
+    }
313
+
314
+    public static function getMarkAsSentAction(string $action = Action::class): MountableAction
315
+    {
316
+        return $action::make('markAsSent')
317
+            ->label('Mark as Sent')
318
+            ->icon('heroicon-o-paper-airplane')
319
+            ->visible(static function (self $record) {
320
+                return ! $record->last_sent;
321
+            })
322
+            ->successNotificationTitle('Invoice Sent')
323
+            ->action(function (self $record, MountableAction $action) {
324
+                $record->update([
325
+                    'status' => InvoiceStatus::Sent,
326
+                    'last_sent' => now(),
327
+                ]);
328
+
329
+                $action->success();
330
+            });
331
+    }
332
+
333
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
334
+    {
335
+        return $action::make()
336
+            ->excludeAttributes([
337
+                'status',
338
+                'amount_paid',
339
+                'amount_due',
340
+                'created_by',
341
+                'updated_by',
342
+                'created_at',
343
+                'updated_at',
344
+                'invoice_number',
345
+                'date',
346
+                'due_date',
347
+                'approved_at',
348
+                'paid_at',
349
+                'last_sent',
350
+            ])
351
+            ->modal(false)
352
+            ->beforeReplicaSaved(function (self $original, self $replica) {
353
+                $replica->status = InvoiceStatus::Draft;
354
+                $replica->invoice_number = self::getNextDocumentNumber();
355
+                $replica->date = now();
356
+                $replica->due_date = now()->addDays($original->company->defaultInvoice->payment_terms->getDays());
357
+            })
358
+            ->databaseTransaction()
359
+            ->after(function (self $original, self $replica) {
360
+                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
361
+                    $replicaLineItem = $lineItem->replicate([
362
+                        'documentable_id',
363
+                        'documentable_type',
364
+                        'subtotal',
365
+                        'total',
366
+                        'created_by',
367
+                        'updated_by',
368
+                        'created_at',
369
+                        'updated_at',
370
+                    ]);
371
+
372
+                    $replicaLineItem->documentable_id = $replica->id;
373
+                    $replicaLineItem->documentable_type = $replica->getMorphClass();
374
+
375
+                    $replicaLineItem->save();
376
+
377
+                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
378
+                });
379
+            })
380
+            ->successRedirectUrl(static function (self $replica) {
381
+                return InvoiceResource::getUrl('edit', ['record' => $replica]);
382
+            });
383
+    }
384
+}

+ 10
- 0
app/Models/Accounting/Transaction.php View File

@@ -5,6 +5,7 @@ namespace App\Models\Accounting;
5 5
 use App\Casts\TransactionAmountCast;
6 6
 use App\Concerns\Blamable;
7 7
 use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\PaymentMethod;
8 9
 use App\Enums\Accounting\TransactionType;
9 10
 use App\Models\Banking\BankAccount;
10 11
 use App\Models\Common\Contact;
@@ -16,6 +17,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
16 17
 use Illuminate\Database\Eloquent\Model;
17 18
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
18 19
 use Illuminate\Database\Eloquent\Relations\HasMany;
20
+use Illuminate\Database\Eloquent\Relations\MorphTo;
19 21
 
20 22
 #[ObservedBy(TransactionObserver::class)]
21 23
 class Transaction extends Model
@@ -32,6 +34,8 @@ class Transaction extends Model
32 34
         'contact_id',
33 35
         'type', // 'deposit', 'withdrawal', 'journal'
34 36
         'payment_channel',
37
+        'payment_method',
38
+        'is_payment',
35 39
         'description',
36 40
         'notes',
37 41
         'reference',
@@ -45,6 +49,7 @@ class Transaction extends Model
45 49
 
46 50
     protected $casts = [
47 51
         'type' => TransactionType::class,
52
+        'payment_method' => PaymentMethod::class,
48 53
         'amount' => TransactionAmountCast::class,
49 54
         'pending' => 'boolean',
50 55
         'reviewed' => 'boolean',
@@ -71,6 +76,11 @@ class Transaction extends Model
71 76
         return $this->hasMany(JournalEntry::class, 'transaction_id');
72 77
     }
73 78
 
79
+    public function transactionable(): MorphTo
80
+    {
81
+        return $this->morphTo();
82
+    }
83
+
74 84
     public function isUncategorized(): bool
75 85
     {
76 86
         return $this->journalEntries->contains(fn (JournalEntry $entry) => $entry->account->isUncategorized());

+ 5
- 4
app/Models/Banking/BankAccount.php View File

@@ -33,6 +33,7 @@ class BankAccount extends Model
33 33
 
34 34
     protected $fillable = [
35 35
         'company_id',
36
+        'account_id',
36 37
         'institution_id',
37 38
         'type',
38 39
         'number',
@@ -50,14 +51,14 @@ class BankAccount extends Model
50 51
         'mask',
51 52
     ];
52 53
 
53
-    public function connectedBankAccount(): HasOne
54
+    public function account(): BelongsTo
54 55
     {
55
-        return $this->hasOne(ConnectedBankAccount::class, 'bank_account_id');
56
+        return $this->belongsTo(Account::class, 'account_id');
56 57
     }
57 58
 
58
-    public function account(): HasOne
59
+    public function connectedBankAccount(): HasOne
59 60
     {
60
-        return $this->hasOne(Account::class, 'bank_account_id');
61
+        return $this->hasOne(ConnectedBankAccount::class, 'bank_account_id');
61 62
     }
62 63
 
63 64
     public function institution(): BelongsTo

+ 44
- 0
app/Models/Common/Address.php View File

@@ -0,0 +1,44 @@
1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Common\AddressType;
8
+use Illuminate\Database\Eloquent\Factories\HasFactory;
9
+use Illuminate\Database\Eloquent\Model;
10
+use Illuminate\Database\Eloquent\Relations\MorphTo;
11
+
12
+class Address extends Model
13
+{
14
+    use Blamable;
15
+    use CompanyOwned;
16
+    use HasFactory;
17
+
18
+    protected $table = 'addresses';
19
+
20
+    protected $fillable = [
21
+        'company_id',
22
+        'type',
23
+        'recipient',
24
+        'phone',
25
+        'address_line_1',
26
+        'address_line_2',
27
+        'city',
28
+        'state',
29
+        'postal_code',
30
+        'country',
31
+        'notes',
32
+        'created_by',
33
+        'updated_by',
34
+    ];
35
+
36
+    protected $casts = [
37
+        'type' => AddressType::class,
38
+    ];
39
+
40
+    public function addressable(): MorphTo
41
+    {
42
+        return $this->morphTo();
43
+    }
44
+}

+ 79
- 0
app/Models/Common/Client.php View File

@@ -0,0 +1,79 @@
1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Common\AddressType;
8
+use App\Models\Accounting\Invoice;
9
+use App\Models\Setting\Currency;
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 Illuminate\Database\Eloquent\Relations\MorphMany;
15
+use Illuminate\Database\Eloquent\Relations\MorphOne;
16
+
17
+class Client extends Model
18
+{
19
+    use Blamable;
20
+    use CompanyOwned;
21
+    use HasFactory;
22
+
23
+    protected $table = 'clients';
24
+
25
+    protected $fillable = [
26
+        'company_id',
27
+        'name',
28
+        'currency_code',
29
+        'account_number',
30
+        'website',
31
+        'notes',
32
+        'created_by',
33
+        'updated_by',
34
+    ];
35
+
36
+    public function contacts(): MorphMany
37
+    {
38
+        return $this->morphMany(Contact::class, 'contactable');
39
+    }
40
+
41
+    public function primaryContact(): MorphOne
42
+    {
43
+        return $this->morphOne(Contact::class, 'contactable')
44
+            ->where('is_primary', true);
45
+    }
46
+
47
+    public function secondaryContacts(): MorphMany
48
+    {
49
+        return $this->morphMany(Contact::class, 'contactable')
50
+            ->where('is_primary', false);
51
+    }
52
+
53
+    public function currency(): BelongsTo
54
+    {
55
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
56
+    }
57
+
58
+    public function addresses(): MorphMany
59
+    {
60
+        return $this->morphMany(Address::class, 'addressable');
61
+    }
62
+
63
+    public function billingAddress(): MorphOne
64
+    {
65
+        return $this->morphOne(Address::class, 'addressable')
66
+            ->where('type', AddressType::Billing);
67
+    }
68
+
69
+    public function shippingAddress(): MorphOne
70
+    {
71
+        return $this->morphOne(Address::class, 'addressable')
72
+            ->where('type', AddressType::Shipping);
73
+    }
74
+
75
+    public function invoices(): HasMany
76
+    {
77
+        return $this->hasMany(Invoice::class);
78
+    }
79
+}

+ 72
- 22
app/Models/Common/Contact.php View File

@@ -5,14 +5,12 @@ namespace App\Models\Common;
5 5
 use App\Concerns\Blamable;
6 6
 use App\Concerns\CompanyOwned;
7 7
 use App\Enums\Common\ContactType;
8
-use App\Models\Setting\Currency;
9 8
 use Database\Factories\Common\ContactFactory;
9
+use Illuminate\Database\Eloquent\Casts\Attribute;
10 10
 use Illuminate\Database\Eloquent\Factories\Factory;
11 11
 use Illuminate\Database\Eloquent\Factories\HasFactory;
12 12
 use Illuminate\Database\Eloquent\Model;
13
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
-use Illuminate\Database\Eloquent\Relations\HasOne;
15
-use Wallo\FilamentCompanies\FilamentCompanies;
13
+use Illuminate\Database\Eloquent\Relations\MorphTo;
16 14
 
17 15
 class Contact extends Model
18 16
 {
@@ -25,37 +23,89 @@ class Contact extends Model
25 23
     protected $fillable = [
26 24
         'company_id',
27 25
         'type',
28
-        'name',
26
+        'first_name',
27
+        'last_name',
29 28
         'email',
30
-        'address',
31
-        'city_id',
32
-        'zip_code',
33
-        'state_id',
34
-        'country',
35
-        'timezone',
36
-        'language',
37
-        'contact_method',
38
-        'phone_number',
39
-        'tax_id',
40
-        'currency_code',
41
-        'website',
42
-        'reference',
29
+        'phones',
30
+        'is_primary',
43 31
         'created_by',
44 32
         'updated_by',
45 33
     ];
46 34
 
47 35
     protected $casts = [
48 36
         'type' => ContactType::class,
37
+        'phones' => 'array',
49 38
     ];
50 39
 
51
-    public function currency(): BelongsTo
40
+    public function contactable(): MorphTo
52 41
     {
53
-        return $this->belongsTo(Currency::class, 'currency_code');
42
+        return $this->morphTo();
54 43
     }
55 44
 
56
-    public function employeeship(): HasOne
45
+    protected function fullName(): Attribute
57 46
     {
58
-        return $this->hasOne(FilamentCompanies::employeeshipModel(), 'contact_id');
47
+        return Attribute::get(function () {
48
+            return trim("{$this->first_name} {$this->last_name}");
49
+        });
50
+    }
51
+
52
+    protected function primaryPhone(): Attribute
53
+    {
54
+        return Attribute::get(function () {
55
+            return $this->getPhoneByType('primary');
56
+        });
57
+    }
58
+
59
+    protected function mobilePhone(): Attribute
60
+    {
61
+        return Attribute::get(function () {
62
+            return $this->getPhoneByType('mobile');
63
+        });
64
+    }
65
+
66
+    protected function faxPhone(): Attribute
67
+    {
68
+        return Attribute::get(function () {
69
+            return $this->getPhoneByType('fax');
70
+        });
71
+    }
72
+
73
+    protected function tollFreePhone(): Attribute
74
+    {
75
+        return Attribute::get(function () {
76
+            return $this->getPhoneByType('toll_free');
77
+        });
78
+    }
79
+
80
+    protected function firstAvailablePhone(): Attribute
81
+    {
82
+        return Attribute::get(function () {
83
+            $priority = ['primary', 'mobile', 'toll_free', 'fax'];
84
+
85
+            foreach ($priority as $type) {
86
+                $phone = $this->getPhoneByType($type);
87
+                if ($phone) {
88
+                    return $phone;
89
+                }
90
+            }
91
+
92
+            return null; // Return null if no phone numbers are available
93
+        });
94
+    }
95
+
96
+    private function getPhoneByType(string $type): ?string
97
+    {
98
+        if (! is_array($this->phones)) {
99
+            return null;
100
+        }
101
+
102
+        foreach ($this->phones as $phone) {
103
+            if ($phone['type'] === $type) {
104
+                return $phone['data']['number'] ?? null;
105
+            }
106
+        }
107
+
108
+        return null;
59 109
     }
60 110
 
61 111
     protected static function newFactory(): Factory

+ 114
- 0
app/Models/Common/Offering.php View File

@@ -0,0 +1,114 @@
1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Concerns\Blamable;
7
+use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\AdjustmentCategory;
9
+use App\Enums\Accounting\AdjustmentType;
10
+use App\Enums\Common\OfferingType;
11
+use App\Models\Accounting\Account;
12
+use App\Models\Accounting\Adjustment;
13
+use App\Observers\OfferingObserver;
14
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
15
+use Illuminate\Database\Eloquent\Factories\HasFactory;
16
+use Illuminate\Database\Eloquent\Model;
17
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
18
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
19
+
20
+#[ObservedBy(OfferingObserver::class)]
21
+class Offering extends Model
22
+{
23
+    use Blamable;
24
+    use CompanyOwned;
25
+    use HasFactory;
26
+
27
+    protected $fillable = [
28
+        'company_id',
29
+        'name',
30
+        'description',
31
+        'type',
32
+        'price',
33
+        'sellable',
34
+        'purchasable',
35
+        'income_account_id',
36
+        'expense_account_id',
37
+        'created_by',
38
+        'updated_by',
39
+    ];
40
+
41
+    protected $casts = [
42
+        'type' => OfferingType::class,
43
+        'price' => MoneyCast::class,
44
+        'sellable' => 'boolean',
45
+        'purchasable' => 'boolean',
46
+    ];
47
+
48
+    public function clearSellableAdjustments(): void
49
+    {
50
+        if (! $this->sellable) {
51
+            $this->income_account_id = null;
52
+
53
+            $adjustmentIds = $this->salesAdjustments()->pluck('adjustment_id');
54
+
55
+            $this->adjustments()->detach($adjustmentIds);
56
+        }
57
+    }
58
+
59
+    public function clearPurchasableAdjustments(): void
60
+    {
61
+        if (! $this->purchasable) {
62
+            $this->expense_account_id = null;
63
+
64
+            $adjustmentIds = $this->purchaseAdjustments()->pluck('adjustment_id');
65
+
66
+            $this->adjustments()->detach($adjustmentIds);
67
+        }
68
+    }
69
+
70
+    public function incomeAccount(): BelongsTo
71
+    {
72
+        return $this->belongsTo(Account::class, 'income_account_id');
73
+    }
74
+
75
+    public function expenseAccount(): BelongsTo
76
+    {
77
+        return $this->belongsTo(Account::class, 'expense_account_id');
78
+    }
79
+
80
+    public function adjustments(): MorphToMany
81
+    {
82
+        return $this->morphToMany(Adjustment::class, 'adjustmentable', 'adjustmentables');
83
+    }
84
+
85
+    public function salesAdjustments(): MorphToMany
86
+    {
87
+        return $this->adjustments()->where('type', AdjustmentType::Sales);
88
+    }
89
+
90
+    public function purchaseAdjustments(): MorphToMany
91
+    {
92
+        return $this->adjustments()->where('type', AdjustmentType::Purchase);
93
+    }
94
+
95
+    public function salesTaxes(): MorphToMany
96
+    {
97
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Sales);
98
+    }
99
+
100
+    public function purchaseTaxes(): MorphToMany
101
+    {
102
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Purchase);
103
+    }
104
+
105
+    public function salesDiscounts(): MorphToMany
106
+    {
107
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Sales);
108
+    }
109
+
110
+    public function purchaseDiscounts(): MorphToMany
111
+    {
112
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Purchase);
113
+    }
114
+}

+ 66
- 0
app/Models/Common/Vendor.php View File

@@ -0,0 +1,66 @@
1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Common\ContractorType;
8
+use App\Enums\Common\VendorType;
9
+use App\Models\Accounting\Bill;
10
+use App\Models\Setting\Currency;
11
+use Illuminate\Database\Eloquent\Factories\HasFactory;
12
+use Illuminate\Database\Eloquent\Model;
13
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
+use Illuminate\Database\Eloquent\Relations\HasMany;
15
+use Illuminate\Database\Eloquent\Relations\MorphOne;
16
+
17
+class Vendor extends Model
18
+{
19
+    use Blamable;
20
+    use CompanyOwned;
21
+    use HasFactory;
22
+
23
+    protected $table = 'vendors';
24
+
25
+    protected $fillable = [
26
+        'company_id',
27
+        'name',
28
+        'type',
29
+        'contractor_type',
30
+        'ssn',
31
+        'ein',
32
+        'currency_code',
33
+        'account_number',
34
+        'website',
35
+        'notes',
36
+        'created_by',
37
+        'updated_by',
38
+    ];
39
+
40
+    protected $casts = [
41
+        'type' => VendorType::class,
42
+        'contractor_type' => ContractorType::class,
43
+        'ssn' => 'encrypted',
44
+        'ein' => 'encrypted',
45
+    ];
46
+
47
+    public function bills(): HasMany
48
+    {
49
+        return $this->hasMany(Bill::class);
50
+    }
51
+
52
+    public function currency(): BelongsTo
53
+    {
54
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
55
+    }
56
+
57
+    public function address(): MorphOne
58
+    {
59
+        return $this->morphOne(Address::class, 'addressable');
60
+    }
61
+
62
+    public function contact(): MorphOne
63
+    {
64
+        return $this->morphOne(Contact::class, 'contactable');
65
+    }
66
+}

+ 33
- 8
app/Models/Company.php View File

@@ -6,16 +6,16 @@ use App\Enums\Setting\DocumentType;
6 6
 use App\Models\Accounting\AccountSubtype;
7 7
 use App\Models\Banking\BankAccount;
8 8
 use App\Models\Banking\ConnectedBankAccount;
9
+use App\Models\Common\Client;
9 10
 use App\Models\Common\Contact;
11
+use App\Models\Common\Offering;
10 12
 use App\Models\Core\Department;
11 13
 use App\Models\Setting\Appearance;
12 14
 use App\Models\Setting\CompanyDefault;
13 15
 use App\Models\Setting\CompanyProfile;
14 16
 use App\Models\Setting\Currency;
15
-use App\Models\Setting\Discount;
16 17
 use App\Models\Setting\DocumentDefault;
17 18
 use App\Models\Setting\Localization;
18
-use App\Models\Setting\Tax;
19 19
 use Filament\Models\Contracts\HasAvatar;
20 20
 use Illuminate\Database\Eloquent\Factories\HasFactory;
21 21
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -74,11 +74,26 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
74 74
         return $this->hasMany(Accounting\Account::class, 'company_id');
75 75
     }
76 76
 
77
+    public function addresses(): HasMany
78
+    {
79
+        return $this->hasMany(Common\Address::class, 'company_id');
80
+    }
81
+
82
+    public function adjustments(): HasMany
83
+    {
84
+        return $this->hasMany(Accounting\Adjustment::class, 'company_id');
85
+    }
86
+
77 87
     public function bankAccounts(): HasMany
78 88
     {
79 89
         return $this->hasMany(BankAccount::class, 'company_id');
80 90
     }
81 91
 
92
+    public function bills(): HasMany
93
+    {
94
+        return $this->hasMany(Accounting\Bill::class, 'company_id');
95
+    }
96
+
82 97
     public function appearance(): HasOne
83 98
     {
84 99
         return $this->hasOne(Appearance::class, 'company_id');
@@ -90,6 +105,11 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
90 105
 
91 106
     }
92 107
 
108
+    public function clients(): HasMany
109
+    {
110
+        return $this->hasMany(Client::class, 'company_id');
111
+    }
112
+
93 113
     public function contacts(): HasMany
94 114
     {
95 115
         return $this->hasMany(Contact::class, 'company_id');
@@ -122,9 +142,9 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
122 142
         return $this->hasMany(Department::class, 'company_id');
123 143
     }
124 144
 
125
-    public function discounts(): HasMany
145
+    public function invoices(): HasMany
126 146
     {
127
-        return $this->hasMany(Discount::class, 'company_id');
147
+        return $this->hasMany(Accounting\Invoice::class, 'company_id');
128 148
     }
129 149
 
130 150
     public function locale(): HasOne
@@ -137,13 +157,18 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
137 157
         return $this->hasOne(CompanyProfile::class, 'company_id');
138 158
     }
139 159
 
140
-    public function taxes(): HasMany
160
+    public function transactions(): HasMany
141 161
     {
142
-        return $this->hasMany(Tax::class, 'company_id');
162
+        return $this->hasMany(Accounting\Transaction::class, 'company_id');
143 163
     }
144 164
 
145
-    public function transactions(): HasMany
165
+    public function offerings(): HasMany
146 166
     {
147
-        return $this->hasMany(Accounting\Transaction::class, 'company_id');
167
+        return $this->hasMany(Offering::class, 'company_id');
168
+    }
169
+
170
+    public function vendors(): HasMany
171
+    {
172
+        return $this->hasMany(Common\Vendor::class, 'company_id');
148 173
     }
149 174
 }

+ 0
- 6
app/Models/Setting/Appearance.php View File

@@ -6,8 +6,6 @@ use App\Concerns\Blamable;
6 6
 use App\Concerns\CompanyOwned;
7 7
 use App\Enums\Setting\Font;
8 8
 use App\Enums\Setting\PrimaryColor;
9
-use App\Enums\Setting\RecordsPerPage;
10
-use App\Enums\Setting\TableSortDirection;
11 9
 use Database\Factories\Setting\AppearanceFactory;
12 10
 use Illuminate\Database\Eloquent\Factories\Factory;
13 11
 use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -25,8 +23,6 @@ class Appearance extends Model
25 23
         'company_id',
26 24
         'primary_color',
27 25
         'font',
28
-        'table_sort_direction',
29
-        'records_per_page',
30 26
         'created_by',
31 27
         'updated_by',
32 28
     ];
@@ -34,8 +30,6 @@ class Appearance extends Model
34 30
     protected $casts = [
35 31
         'primary_color' => PrimaryColor::class,
36 32
         'font' => Font::class,
37
-        'table_sort_direction' => TableSortDirection::class,
38
-        'records_per_page' => RecordsPerPage::class,
39 33
     ];
40 34
 
41 35
     protected static function newFactory(): Factory

+ 0
- 30
app/Models/Setting/CompanyDefault.php View File

@@ -4,8 +4,6 @@ namespace App\Models\Setting;
4 4
 
5 5
 use App\Concerns\Blamable;
6 6
 use App\Concerns\CompanyOwned;
7
-use App\Enums\Setting\DiscountType;
8
-use App\Enums\Setting\TaxType;
9 7
 use App\Models\Banking\BankAccount;
10 8
 use Database\Factories\Setting\CompanyDefaultFactory;
11 9
 use Illuminate\Database\Eloquent\Factories\Factory;
@@ -25,10 +23,6 @@ class CompanyDefault extends Model
25 23
         'company_id',
26 24
         'bank_account_id',
27 25
         'currency_code',
28
-        'sales_tax_id',
29
-        'purchase_tax_id',
30
-        'sales_discount_id',
31
-        'purchase_discount_id',
32 26
         'created_by',
33 27
         'updated_by',
34 28
     ];
@@ -43,30 +37,6 @@ class CompanyDefault extends Model
43 37
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
44 38
     }
45 39
 
46
-    public function salesTax(): BelongsTo
47
-    {
48
-        return $this->belongsTo(Tax::class, 'sales_tax_id', 'id')
49
-            ->where('type', TaxType::Sales);
50
-    }
51
-
52
-    public function purchaseTax(): BelongsTo
53
-    {
54
-        return $this->belongsTo(Tax::class, 'purchase_tax_id', 'id')
55
-            ->where('type', TaxType::Purchase);
56
-    }
57
-
58
-    public function salesDiscount(): BelongsTo
59
-    {
60
-        return $this->belongsTo(Discount::class, 'sales_discount_id', 'id')
61
-            ->where('type', DiscountType::Sales);
62
-    }
63
-
64
-    public function purchaseDiscount(): BelongsTo
65
-    {
66
-        return $this->belongsTo(Discount::class, 'purchase_discount_id', 'id')
67
-            ->where('type', DiscountType::Purchase);
68
-    }
69
-
70 40
     protected static function newFactory(): Factory
71 41
     {
72 42
         return CompanyDefaultFactory::new();

+ 0
- 0
app/Models/Setting/Discount.php View File


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

Loading…
Cancel
Save