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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. <?php
  2. namespace App\Models\Accounting;
  3. use App\Concerns\Blamable;
  4. use App\Concerns\CompanyOwned;
  5. use App\Enums\Accounting\BudgetIntervalType;
  6. use App\Enums\Accounting\BudgetSourceType;
  7. use App\Enums\Accounting\BudgetStatus;
  8. use App\Filament\Company\Resources\Accounting\BudgetResource;
  9. use App\Models\User;
  10. use Filament\Actions\Action;
  11. use Filament\Actions\MountableAction;
  12. use Filament\Actions\ReplicateAction;
  13. use Illuminate\Database\Eloquent\Attributes\Scope;
  14. use Illuminate\Database\Eloquent\Builder;
  15. use Illuminate\Database\Eloquent\Casts\Attribute;
  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\HasMany;
  20. use Illuminate\Database\Eloquent\Relations\HasManyThrough;
  21. use Illuminate\Support\Carbon;
  22. use Illuminate\Support\Collection;
  23. class Budget extends Model
  24. {
  25. use Blamable;
  26. use CompanyOwned;
  27. use HasFactory;
  28. protected $fillable = [
  29. 'company_id',
  30. 'source_budget_id',
  31. 'source_fiscal_year',
  32. 'source_type',
  33. 'name',
  34. 'start_date',
  35. 'end_date',
  36. 'status', // draft, active, closed
  37. 'interval_type', // day, week, month, quarter, year
  38. 'notes',
  39. 'approved_at',
  40. 'approved_by_id',
  41. 'closed_at',
  42. 'created_by',
  43. 'updated_by',
  44. ];
  45. protected $casts = [
  46. 'source_fiscal_year' => 'integer',
  47. 'source_type' => BudgetSourceType::class,
  48. 'start_date' => 'date',
  49. 'end_date' => 'date',
  50. 'status' => BudgetStatus::class,
  51. 'interval_type' => BudgetIntervalType::class,
  52. 'approved_at' => 'datetime',
  53. 'closed_at' => 'datetime',
  54. ];
  55. public function sourceBudget(): BelongsTo
  56. {
  57. return $this->belongsTo(self::class, 'source_budget_id');
  58. }
  59. public function derivedBudgets(): HasMany
  60. {
  61. return $this->hasMany(self::class, 'source_budget_id');
  62. }
  63. public function approvedBy(): BelongsTo
  64. {
  65. return $this->belongsTo(User::class, 'approved_by_id');
  66. }
  67. public function budgetItems(): HasMany
  68. {
  69. return $this->hasMany(BudgetItem::class);
  70. }
  71. public function allocations(): HasManyThrough
  72. {
  73. return $this->hasManyThrough(BudgetAllocation::class, BudgetItem::class);
  74. }
  75. public function getPeriods(): Collection
  76. {
  77. return $this->allocations()
  78. ->select(['period', 'start_date'])
  79. ->distinct()
  80. ->orderBy('start_date')
  81. ->get();
  82. }
  83. public function isDraft(): bool
  84. {
  85. return $this->status === BudgetStatus::Draft;
  86. }
  87. public function isActive(): bool
  88. {
  89. return $this->status === BudgetStatus::Active;
  90. }
  91. public function isClosed(): bool
  92. {
  93. return $this->status === BudgetStatus::Closed;
  94. }
  95. public function wasApproved(): bool
  96. {
  97. return $this->approved_at !== null;
  98. }
  99. public function wasClosed(): bool
  100. {
  101. return $this->closed_at !== null;
  102. }
  103. public function canBeApproved(): bool
  104. {
  105. return $this->isDraft() && ! $this->wasApproved();
  106. }
  107. public function canBeClosed(): bool
  108. {
  109. return $this->isActive() && ! $this->wasClosed();
  110. }
  111. public function hasItems(): bool
  112. {
  113. return $this->budgetItems()->exists();
  114. }
  115. public function hasAllocations(): bool
  116. {
  117. return $this->allocations()->exists();
  118. }
  119. #[Scope]
  120. protected function draft(Builder $query): Builder
  121. {
  122. return $query->where('status', BudgetStatus::Draft);
  123. }
  124. #[Scope]
  125. protected function active(Builder $query): Builder
  126. {
  127. return $query->where('status', BudgetStatus::Active);
  128. }
  129. #[Scope]
  130. protected function closed(Builder $query): Builder
  131. {
  132. return $query->where('status', BudgetStatus::Closed);
  133. }
  134. #[Scope]
  135. protected function currentlyActive(Builder $query): Builder
  136. {
  137. return $query->active()
  138. ->where('start_date', '<=', company_now())
  139. ->where('end_date', '>=', company_now());
  140. }
  141. protected function isCurrentlyInPeriod(): Attribute
  142. {
  143. return Attribute::get(function () {
  144. return company_now()->between($this->start_date, $this->end_date);
  145. });
  146. }
  147. /**
  148. * Approve a draft budget
  149. */
  150. public function approveDraft(?Carbon $approvedAt = null): void
  151. {
  152. if (! $this->canBeApproved()) {
  153. throw new \RuntimeException('Budget cannot be approved.');
  154. }
  155. $approvedAt ??= company_now();
  156. $this->update([
  157. 'status' => BudgetStatus::Active,
  158. 'approved_at' => $approvedAt,
  159. ]);
  160. }
  161. /**
  162. * Close an active budget
  163. */
  164. public function close(?Carbon $closedAt = null): void
  165. {
  166. if (! $this->canBeClosed()) {
  167. throw new \RuntimeException('Budget cannot be closed.');
  168. }
  169. $closedAt ??= company_now();
  170. $this->update([
  171. 'status' => BudgetStatus::Closed,
  172. 'closed_at' => $closedAt,
  173. ]);
  174. }
  175. /**
  176. * Reopen a closed budget
  177. */
  178. public function reopen(): void
  179. {
  180. if (! $this->isClosed()) {
  181. throw new \RuntimeException('Only closed budgets can be reopened.');
  182. }
  183. $this->update([
  184. 'status' => BudgetStatus::Active,
  185. 'closed_at' => null,
  186. ]);
  187. }
  188. /**
  189. * Get Action for approving a draft budget
  190. */
  191. public static function getApproveDraftAction(string $action = Action::class): MountableAction
  192. {
  193. return $action::make('approveDraft')
  194. ->label('Approve')
  195. ->icon('heroicon-m-check-circle')
  196. ->visible(function (self $record) {
  197. return $record->canBeApproved();
  198. })
  199. ->databaseTransaction()
  200. ->successNotificationTitle('Budget approved')
  201. ->action(function (self $record, MountableAction $action) {
  202. $record->approveDraft();
  203. $action->success();
  204. });
  205. }
  206. /**
  207. * Get Action for closing an active budget
  208. */
  209. public static function getCloseAction(string $action = Action::class): MountableAction
  210. {
  211. return $action::make('close')
  212. ->label('Close')
  213. ->icon('heroicon-m-lock-closed')
  214. ->color('warning')
  215. ->visible(function (self $record) {
  216. return $record->canBeClosed();
  217. })
  218. ->requiresConfirmation()
  219. ->databaseTransaction()
  220. ->successNotificationTitle('Budget closed')
  221. ->action(function (self $record, MountableAction $action) {
  222. $record->close();
  223. $action->success();
  224. });
  225. }
  226. /**
  227. * Get Action for reopening a closed budget
  228. */
  229. public static function getReopenAction(string $action = Action::class): MountableAction
  230. {
  231. return $action::make('reopen')
  232. ->label('Reopen')
  233. ->icon('heroicon-m-lock-open')
  234. ->visible(function (self $record) {
  235. return $record->isClosed();
  236. })
  237. ->requiresConfirmation()
  238. ->databaseTransaction()
  239. ->successNotificationTitle('Budget reopened')
  240. ->action(function (self $record, MountableAction $action) {
  241. $record->reopen();
  242. $action->success();
  243. });
  244. }
  245. /**
  246. * Get Action for duplicating a budget
  247. */
  248. public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
  249. {
  250. return $action::make()
  251. ->excludeAttributes([
  252. 'status',
  253. 'approved_at',
  254. 'closed_at',
  255. 'created_by',
  256. 'updated_by',
  257. 'created_at',
  258. 'updated_at',
  259. ])
  260. ->modal(false)
  261. ->beforeReplicaSaved(function (self $original, self $replica) {
  262. $replica->status = BudgetStatus::Draft;
  263. $replica->name = $replica->name . ' (Copy)';
  264. })
  265. ->databaseTransaction()
  266. ->after(function (self $original, self $replica) {
  267. // Clone budget items and their allocations
  268. $original->budgetItems->each(function (BudgetItem $item) use ($replica) {
  269. $newItem = $item->replicate([
  270. 'budget_id',
  271. 'created_by',
  272. 'updated_by',
  273. 'created_at',
  274. 'updated_at',
  275. ]);
  276. $newItem->budget_id = $replica->id;
  277. $newItem->save();
  278. // Clone the allocations for this budget item
  279. $item->allocations->each(function (BudgetAllocation $allocation) use ($newItem) {
  280. $newAllocation = $allocation->replicate([
  281. 'budget_item_id',
  282. 'created_at',
  283. 'updated_at',
  284. ]);
  285. $newAllocation->budget_item_id = $newItem->id;
  286. $newAllocation->save();
  287. });
  288. });
  289. })
  290. ->successRedirectUrl(static function (self $replica) {
  291. return BudgetResource::getUrl('edit', ['record' => $replica]);
  292. });
  293. }
  294. }