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.

BillFactory.php 8.6KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. <?php
  2. namespace Database\Factories\Accounting;
  3. use App\Enums\Accounting\AdjustmentComputation;
  4. use App\Enums\Accounting\BillStatus;
  5. use App\Enums\Accounting\DocumentDiscountMethod;
  6. use App\Enums\Accounting\PaymentMethod;
  7. use App\Models\Accounting\Bill;
  8. use App\Models\Accounting\DocumentLineItem;
  9. use App\Models\Banking\BankAccount;
  10. use App\Models\Common\Vendor;
  11. use App\Models\Company;
  12. use App\Models\Setting\DocumentDefault;
  13. use App\Utilities\RateCalculator;
  14. use Illuminate\Database\Eloquent\Factories\Factory;
  15. use Illuminate\Support\Carbon;
  16. /**
  17. * @extends Factory<Bill>
  18. */
  19. class BillFactory extends Factory
  20. {
  21. /**
  22. * The name of the factory's corresponding model.
  23. */
  24. protected $model = Bill::class;
  25. /**
  26. * Define the model's default state.
  27. *
  28. * @return array<string, mixed>
  29. */
  30. public function definition(): array
  31. {
  32. $isFutureBill = $this->faker->boolean();
  33. if ($isFutureBill) {
  34. $billDate = $this->faker->dateTimeBetween('-10 days', '+10 days');
  35. } else {
  36. $billDate = $this->faker->dateTimeBetween('-1 year', '-30 days');
  37. }
  38. $dueDays = $this->faker->numberBetween(14, 60);
  39. return [
  40. 'company_id' => 1,
  41. 'vendor_id' => fn (array $attributes) => Vendor::where('company_id', $attributes['company_id'])->inRandomOrder()->value('id'),
  42. 'bill_number' => $this->faker->unique()->numerify('BILL-####'),
  43. 'order_number' => $this->faker->unique()->numerify('PO-####'),
  44. 'date' => $billDate,
  45. 'due_date' => Carbon::parse($billDate)->addDays($dueDays),
  46. 'status' => BillStatus::Open,
  47. 'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
  48. 'discount_computation' => AdjustmentComputation::Percentage,
  49. 'discount_rate' => function (array $attributes) {
  50. $discountMethod = DocumentDiscountMethod::parse($attributes['discount_method']);
  51. if ($discountMethod?->isPerDocument()) {
  52. return $this->faker->numberBetween(50000, 200000); // 5% - 20%
  53. }
  54. return 0;
  55. },
  56. 'currency_code' => function (array $attributes) {
  57. $vendor = Vendor::find($attributes['vendor_id']);
  58. return $vendor->currency_code ??
  59. Company::find($attributes['company_id'])->default->currency_code ??
  60. 'USD';
  61. },
  62. 'notes' => $this->faker->sentence,
  63. 'created_by' => 1,
  64. 'updated_by' => 1,
  65. ];
  66. }
  67. public function withLineItems(int $count = 3): static
  68. {
  69. return $this->afterCreating(function (Bill $bill) use ($count) {
  70. DocumentLineItem::factory()
  71. ->count($count)
  72. ->forBill($bill)
  73. ->create();
  74. $this->recalculateTotals($bill);
  75. });
  76. }
  77. public function initialized(): static
  78. {
  79. return $this->afterCreating(function (Bill $bill) {
  80. $this->ensureLineItems($bill);
  81. if ($bill->wasInitialized()) {
  82. return;
  83. }
  84. $postedAt = Carbon::parse($bill->date)
  85. ->addHours($this->faker->numberBetween(1, 24));
  86. $bill->createInitialTransaction($postedAt);
  87. });
  88. }
  89. public function partial(int $maxPayments = 4): static
  90. {
  91. return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
  92. $this->ensureInitialized($bill);
  93. $this->withPayments(max: $maxPayments, billStatus: BillStatus::Partial)
  94. ->callAfterCreating(collect([$bill]));
  95. });
  96. }
  97. public function paid(int $maxPayments = 4): static
  98. {
  99. return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
  100. $this->ensureInitialized($bill);
  101. $this->withPayments(max: $maxPayments)
  102. ->callAfterCreating(collect([$bill]));
  103. });
  104. }
  105. public function overdue(): static
  106. {
  107. return $this
  108. ->state([
  109. 'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
  110. ])
  111. ->afterCreating(function (Bill $bill) {
  112. $this->ensureInitialized($bill);
  113. });
  114. }
  115. public function withPayments(?int $min = null, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
  116. {
  117. $min ??= 1;
  118. return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
  119. $this->ensureInitialized($bill);
  120. $bill->refresh();
  121. $amountDue = $bill->getRawOriginal('amount_due');
  122. $totalAmountDue = match ($billStatus) {
  123. BillStatus::Partial => (int) floor($amountDue * 0.5),
  124. default => $amountDue,
  125. };
  126. if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
  127. return;
  128. }
  129. $paymentCount = $max && $min ? $this->faker->numberBetween($min, $max) : $min;
  130. $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
  131. $remainingAmount = $totalAmountDue;
  132. $paymentDate = Carbon::parse($bill->initialTransaction->posted_at);
  133. $paymentDates = [];
  134. for ($i = 0; $i < $paymentCount; $i++) {
  135. $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
  136. if ($amount <= 0) {
  137. break;
  138. }
  139. $postedAt = $paymentDate->copy()->addDays($this->faker->numberBetween(1, 30));
  140. $paymentDates[] = $postedAt;
  141. $data = [
  142. 'posted_at' => $postedAt,
  143. 'amount' => $amount,
  144. 'payment_method' => $this->faker->randomElement(PaymentMethod::class),
  145. 'bank_account_id' => BankAccount::where('company_id', $bill->company_id)->inRandomOrder()->value('id'),
  146. 'notes' => $this->faker->sentence,
  147. ];
  148. $bill->recordPayment($data);
  149. $remainingAmount -= $amount;
  150. }
  151. if ($billStatus !== BillStatus::Paid) {
  152. return;
  153. }
  154. $latestPaymentDate = max($paymentDates);
  155. $bill->updateQuietly([
  156. 'status' => $billStatus,
  157. 'paid_at' => $latestPaymentDate,
  158. ]);
  159. });
  160. }
  161. public function configure(): static
  162. {
  163. return $this->afterCreating(function (Bill $bill) {
  164. $this->ensureInitialized($bill);
  165. $number = DocumentDefault::getBaseNumber() + $bill->id;
  166. $bill->updateQuietly([
  167. 'bill_number' => "BILL-{$number}",
  168. 'order_number' => "PO-{$number}",
  169. ]);
  170. if ($bill->wasInitialized() && $bill->is_currently_overdue) {
  171. $bill->updateQuietly([
  172. 'status' => BillStatus::Overdue,
  173. ]);
  174. }
  175. });
  176. }
  177. protected function ensureLineItems(Bill $bill): void
  178. {
  179. if (! $bill->hasLineItems()) {
  180. $this->withLineItems()->callAfterCreating(collect([$bill]));
  181. }
  182. }
  183. protected function ensureInitialized(Bill $bill): void
  184. {
  185. if (! $bill->wasInitialized()) {
  186. $this->initialized()->callAfterCreating(collect([$bill]));
  187. }
  188. }
  189. protected function recalculateTotals(Bill $bill): void
  190. {
  191. $bill->refresh();
  192. if (! $bill->hasLineItems()) {
  193. return;
  194. }
  195. $subtotalCents = $bill->lineItems()->sum('subtotal');
  196. $taxTotalCents = $bill->lineItems()->sum('tax_total');
  197. $discountTotalCents = 0;
  198. if ($bill->discount_method?->isPerLineItem()) {
  199. $discountTotalCents = $bill->lineItems()->sum('discount_total');
  200. } elseif ($bill->discount_method?->isPerDocument() && $bill->discount_rate) {
  201. if ($bill->discount_computation?->isPercentage()) {
  202. $scaledRate = RateCalculator::parseLocalizedRate($bill->discount_rate);
  203. $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
  204. } else {
  205. $discountTotalCents = $bill->getRawOriginal('discount_rate');
  206. }
  207. }
  208. $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
  209. $currencyCode = $bill->currency_code;
  210. $bill->update([
  211. 'subtotal' => $subtotalCents,
  212. 'tax_total' => $taxTotalCents,
  213. 'discount_total' => $discountTotalCents,
  214. 'total' => $grandTotalCents,
  215. ]);
  216. }
  217. }