diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 96833671..46f7054d 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -101,7 +101,9 @@ jobs: uses: ramsey/composer-install@4.0.0 - name: Apply tests ${{ matrix.php-version }} (parallel) - run: php artisan test --parallel --exclude-group=modules + run: php artisan test --parallel --exclude-group=modules --exclude-group=architecture - - name: Apply module tests ${{ matrix.php-version }} (serial) - run: php artisan test --group=modules + - name: Apply module and architecture tests ${{ matrix.php-version }} (serial) + run: | + php artisan test --group=modules + php artisan test --group=architecture diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e0a248d7..5048b3ca 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -33,7 +33,9 @@ jobs: uses: ramsey/composer-install@4.0.0 - name: Apply tests ${{ matrix.php-version }} (parallel) - run: php artisan test --parallel --exclude-group=modules + run: php artisan test --parallel --exclude-group=modules --exclude-group=architecture - - name: Apply module tests ${{ matrix.php-version }} (serial) - run: php artisan test --group=modules + - name: Apply module and architecture tests ${{ matrix.php-version }} (serial) + run: | + php artisan test --group=modules + php artisan test --group=architecture diff --git a/app/Adapters/Accounts/EloquentBusinessDefaultsProvisioner.php b/app/Adapters/Accounts/EloquentBusinessDefaultsProvisioner.php new file mode 100644 index 00000000..3b6771c1 --- /dev/null +++ b/app/Adapters/Accounts/EloquentBusinessDefaultsProvisioner.php @@ -0,0 +1,28 @@ + $name, + 'company_id' => $company->id, + ]); + } + + foreach (['box', 'cm', 'dz', 'ft', 'g', 'in', 'kg', 'km', 'lb', 'mg', 'pc'] as $name) { + Unit::create([ + 'name' => $name, + 'company_id' => $company->id, + ]); + } + } +} diff --git a/app/Adapters/Accounts/EloquentCompanyAddressWriter.php b/app/Adapters/Accounts/EloquentCompanyAddressWriter.php new file mode 100644 index 00000000..5a04f777 --- /dev/null +++ b/app/Adapters/Accounts/EloquentCompanyAddressWriter.php @@ -0,0 +1,17 @@ +address()->updateOrCreate( + ['company_id' => $company->id], + $attributes, + ); + } +} diff --git a/app/Adapters/Accounts/EloquentCompanyDataPurger.php b/app/Adapters/Accounts/EloquentCompanyDataPurger.php new file mode 100644 index 00000000..27d179f8 --- /dev/null +++ b/app/Adapters/Accounts/EloquentCompanyDataPurger.php @@ -0,0 +1,59 @@ +exchangeRateLogs()->delete(); + $company->exchangeRateProviders()->delete(); + $company->expenses->each->delete(); + $company->expenseCategories()->delete(); + + PaymentAllocation::query() + ->whereIn('payment_id', $company->payments()->select('id')) + ->delete(); + $company->payments()->delete(); + $company->paymentMethods()->delete(); + $company->customFieldValues()->delete(); + $company->customFields()->delete(); + + $company->invoices->each(function (Model $invoice): void { + $this->clearDocumentData($invoice); + $invoice->transactions()->delete(); + }); + $company->invoices()->delete(); + + $company->recurringInvoices->each(fn (Model $invoice) => $this->clearDocumentData($invoice)); + $company->recurringInvoices()->delete(); + + $company->estimates->each(fn (Model $estimate) => $this->clearDocumentData($estimate)); + $company->estimates()->delete(); + + $company->items()->delete(); + $company->taxTypes()->delete(); + + $company->customers->each(function (Model $customer): void { + $customer->addresses()->delete(); + $customer->delete(); + }); + + $company->address()->delete(); + } + + private function clearDocumentData(Model $document): void + { + $document->items->each(function (Model $item): void { + $item->taxes()->delete(); + $item->delete(); + }); + + $document->taxes()->delete(); + } +} diff --git a/app/Adapters/Accounts/EloquentMemberReferencesCleaner.php b/app/Adapters/Accounts/EloquentMemberReferencesCleaner.php new file mode 100644 index 00000000..91a11767 --- /dev/null +++ b/app/Adapters/Accounts/EloquentMemberReferencesCleaner.php @@ -0,0 +1,20 @@ +invoices()->update(['creator_id' => null]); + $user->estimates()->update(['creator_id' => null]); + $user->customers()->update(['creator_id' => null]); + $user->recurringInvoices()->update(['creator_id' => null]); + $user->expenses()->update(['creator_id' => null]); + $user->payments()->update(['creator_id' => null]); + $user->items()->update(['creator_id' => null]); + } +} diff --git a/app/Adapters/Accounts/LaravelCompanyInvitationSender.php b/app/Adapters/Accounts/LaravelCompanyInvitationSender.php new file mode 100644 index 00000000..ca56120f --- /dev/null +++ b/app/Adapters/Accounts/LaravelCompanyInvitationSender.php @@ -0,0 +1,16 @@ +email)->send(new CompanyInvitationMail($invitation)); + } +} diff --git a/app/Adapters/Accounts/MediaLibraryCompanyLogoManager.php b/app/Adapters/Accounts/MediaLibraryCompanyLogoManager.php new file mode 100644 index 00000000..4e254424 --- /dev/null +++ b/app/Adapters/Accounts/MediaLibraryCompanyLogoManager.php @@ -0,0 +1,24 @@ +clearMediaCollection(self::COLLECTION); + } + + public function replaceBase64(Company $company, string $contents, string $fileName): void + { + $this->clear($company); + $company->addMediaFromBase64($contents) + ->usingFileName($fileName) + ->toMediaCollection(self::COLLECTION); + } +} diff --git a/app/Adapters/Accounts/MediaLibraryUserAvatarManager.php b/app/Adapters/Accounts/MediaLibraryUserAvatarManager.php new file mode 100644 index 00000000..bc6366dd --- /dev/null +++ b/app/Adapters/Accounts/MediaLibraryUserAvatarManager.php @@ -0,0 +1,32 @@ +clearMediaCollection(self::COLLECTION); + } + + public function replaceFile(User $user, string $path, string $fileName): void + { + $this->clear($user); + $user->addMedia($path) + ->usingFileName($fileName) + ->toMediaCollection(self::COLLECTION); + } + + public function replaceBase64(User $user, string $contents, string $fileName): void + { + $this->clear($user); + $user->addMediaFromBase64($contents) + ->usingFileName($fileName) + ->toMediaCollection(self::COLLECTION); + } +} diff --git a/app/Adapters/Catalog/TaxationItemTaxManager.php b/app/Adapters/Catalog/TaxationItemTaxManager.php new file mode 100644 index 00000000..f7e7acec --- /dev/null +++ b/app/Adapters/Catalog/TaxationItemTaxManager.php @@ -0,0 +1,32 @@ +update(['tax_per_item' => true]); + + foreach ($taxes as $tax) { + $item->taxes()->create([ + ...$tax, + 'company_id' => $companyId, + ]); + } + } + + public function replace(Item $item, array $taxes, int $companyId): void + { + $item->taxes()->delete(); + + $this->attach($item, $taxes, $companyId); + } +} diff --git a/app/Adapters/Contacts/EloquentCustomerDataPurger.php b/app/Adapters/Contacts/EloquentCustomerDataPurger.php new file mode 100644 index 00000000..603380af --- /dev/null +++ b/app/Adapters/Contacts/EloquentCustomerDataPurger.php @@ -0,0 +1,50 @@ +estimates->each(function (Model $estimate): void { + $this->clearDocumentData($estimate); + $estimate->delete(); + }); + + PaymentAllocation::query() + ->whereIn('payment_id', $customer->payments()->select('id')) + ->delete(); + $customer->payments->each->delete(); + + $invoiceIds = $customer->invoices()->pluck('id'); + $customer->invoices->each(function (Invoice $invoice): void { + $this->clearDocumentData($invoice); + $invoice->transactions()->delete(); + $invoice->delete(); + }); + Invoice::query()->whereIn('related_invoice_id', $invoiceIds)->update(['related_invoice_id' => null]); + + $customer->expenses->each->delete(); + + $customer->recurringInvoices->each(function (Model $recurringInvoice): void { + $this->clearDocumentData($recurringInvoice); + $recurringInvoice->delete(); + }); + } + + private function clearDocumentData(Model $document): void + { + $document->items->each(function (Model $item): void { + $item->taxes()->delete(); + $item->delete(); + }); + + $document->taxes()->delete(); + } +} diff --git a/app/Adapters/Contacts/EloquentCustomerPortalDashboardProvider.php b/app/Adapters/Contacts/EloquentCustomerPortalDashboardProvider.php new file mode 100644 index 00000000..064e1b32 --- /dev/null +++ b/app/Adapters/Contacts/EloquentCustomerPortalDashboardProvider.php @@ -0,0 +1,33 @@ +id) + ->where('status', '<>', 'DRAFT'); + + return [ + 'due_amount' => (clone $issuedInvoices)->sum('due_amount'), + 'recentInvoices' => (clone $issuedInvoices)->take(5)->latest()->get(), + 'recentEstimates' => Estimate::whereCustomer($customer->id) + ->where('status', '<>', 'DRAFT') + ->take(5) + ->latest() + ->get(), + 'invoice_count' => (clone $issuedInvoices)->where('type', Invoice::TYPE_INVOICE)->count(), + 'estimate_count' => Estimate::whereCustomer($customer->id) + ->where('status', '<>', 'DRAFT') + ->count(), + 'payment_count' => Payment::whereCustomer($customer->id)->count(), + ]; + } +} diff --git a/app/Adapters/Contacts/EloquentCustomerStatsProvider.php b/app/Adapters/Contacts/EloquentCustomerStatsProvider.php new file mode 100644 index 00000000..965c5a6f --- /dev/null +++ b/app/Adapters/Contacts/EloquentCustomerStatsProvider.php @@ -0,0 +1,114 @@ +month) { + $startDate->month($companyStartMonth)->startOfMonth(); + $start->month($companyStartMonth)->startOfMonth(); + $end->month($companyStartMonth)->endOfMonth(); + } else { + $startDate->subYear()->month($companyStartMonth)->startOfMonth(); + $start->subYear()->month($companyStartMonth)->startOfMonth(); + $end->subYear()->month($companyStartMonth)->endOfMonth(); + } + + if ($previousYear) { + $startDate->subYear()->startOfMonth(); + $start->subYear()->startOfMonth(); + $end->subYear()->endOfMonth(); + } + + while ($monthCounter < 12) { + $invoiceTotals[] = Invoice::whereBetween( + 'invoice_date', + [$start->format('Y-m-d'), $end->format('Y-m-d')] + ) + ->whereCompany() + ->whereCustomer($customer->id) + ->sum('base_total') ?? 0; + $expenseTotals[] = Expense::whereBetween( + 'expense_date', + [$start->format('Y-m-d'), $end->format('Y-m-d')] + ) + ->whereCompany() + ->whereUser($customer->id) + ->sum('base_amount') ?? 0; + $receiptTotals[] = Payment::whereBetween( + 'payment_date', + [$start->format('Y-m-d'), $end->format('Y-m-d')] + ) + ->whereCompany() + ->whereCustomer($customer->id) + ->sum('base_amount') ?? 0; + $netProfits[] = $receiptTotals[$i] - $expenseTotals[$i]; + $i++; + $months[] = $start->translatedFormat('M'); + $monthCounter++; + $end->startOfMonth(); + $start->addMonth()->startOfMonth(); + $end->addMonth()->endOfMonth(); + } + + $start->subMonth()->endOfMonth(); + + $salesTotal = Invoice::whereBetween( + 'invoice_date', + [$startDate->format('Y-m-d'), $start->format('Y-m-d')] + ) + ->whereCompany() + ->whereCustomer($customer->id) + ->sum('base_total'); + $totalReceipts = Payment::whereBetween( + 'payment_date', + [$startDate->format('Y-m-d'), $start->format('Y-m-d')] + ) + ->whereCompany() + ->whereCustomer($customer->id) + ->sum('base_amount'); + $totalExpenses = Expense::whereBetween( + 'expense_date', + [$startDate->format('Y-m-d'), $start->format('Y-m-d')] + ) + ->whereCompany() + ->whereUser($customer->id) + ->sum('base_amount'); + + return [ + 'months' => $months, + 'invoiceTotals' => $invoiceTotals, + 'expenseTotals' => $expenseTotals, + 'receiptTotals' => $receiptTotals, + 'netProfit' => (int) $totalReceipts - (int) $totalExpenses, + 'netProfits' => $netProfits, + 'salesTotal' => $salesTotal, + 'totalReceipts' => $totalReceipts, + 'totalExpenses' => $totalExpenses, + ]; + } +} diff --git a/app/Adapters/Contacts/MediaLibraryCustomerAvatarManager.php b/app/Adapters/Contacts/MediaLibraryCustomerAvatarManager.php new file mode 100644 index 00000000..6d0e34d4 --- /dev/null +++ b/app/Adapters/Contacts/MediaLibraryCustomerAvatarManager.php @@ -0,0 +1,24 @@ +clearMediaCollection(self::COLLECTION); + } + + public function replace(Customer $customer, string $path, string $fileName): void + { + $this->clear($customer); + $customer->addMedia($path) + ->usingFileName($fileName) + ->toMediaCollection(self::COLLECTION); + } +} diff --git a/app/Adapters/Money/EloquentExchangeRateBackfill.php b/app/Adapters/Money/EloquentExchangeRateBackfill.php new file mode 100644 index 00000000..5f18e8f5 --- /dev/null +++ b/app/Adapters/Money/EloquentExchangeRateBackfill.php @@ -0,0 +1,111 @@ +pluck('currency_id')->all(), + Tax::whereNull('exchange_rate')->pluck('currency_id')->all(), + Estimate::whereNull('exchange_rate')->pluck('currency_id')->all(), + Payment::whereNull('exchange_rate')->pluck('currency_id')->all(), + ); + } + + public function apply(int $companyId, array $currencies): bool + { + if (CompanySetting::getSetting('bulk_exchange_rate_configured', $companyId) !== 'NO') { + return false; + } + + foreach ($currencies as $currency) { + $rate = $currency['exchange_rate'] ?? 1; + + foreach (Invoice::where('currency_id', $currency['id'])->get() as $invoice) { + $invoice->update([ + 'exchange_rate' => $rate, + 'base_discount_val' => $invoice->sub_total * $rate, + 'base_sub_total' => $invoice->sub_total * $rate, + 'base_total' => $invoice->total * $rate, + 'base_tax' => $invoice->tax * $rate, + 'base_due_amount' => $invoice->due_amount * $rate, + ]); + + $this->updateItemsExchangeRate($invoice); + } + + foreach (Estimate::where('currency_id', $currency['id'])->get() as $estimate) { + $estimate->update([ + 'exchange_rate' => $rate, + 'base_discount_val' => $estimate->sub_total * $rate, + 'base_sub_total' => $estimate->sub_total * $rate, + 'base_total' => $estimate->total * $rate, + 'base_tax' => $estimate->tax * $rate, + ]); + + $this->updateItemsExchangeRate($estimate); + } + + foreach (Tax::where('currency_id', $currency['id'])->get() as $tax) { + $tax->update(['base_amount' => $tax->base_amount * $rate]); + } + + foreach (Payment::where('currency_id', $currency['id'])->get() as $payment) { + $payment->update([ + 'exchange_rate' => $rate, + 'base_amount' => $payment->amount * $rate, + ]); + } + } + + CompanySetting::setSettings([ + 'bulk_exchange_rate_configured' => 'YES', + ], $companyId); + + return true; + } + + private function updateItemsExchangeRate(mixed $document): void + { + foreach ($document->items as $item) { + $item->update([ + 'exchange_rate' => $document->exchange_rate, + 'base_discount_val' => $item->discount_val * $document->exchange_rate, + 'base_price' => $item->price * $document->exchange_rate, + 'base_tax' => $item->tax * $document->exchange_rate, + 'base_total' => $item->total * $document->exchange_rate, + ]); + + $this->updateTaxesExchangeRate($item); + } + + $this->updateTaxesExchangeRate($document); + } + + private function updateTaxesExchangeRate(mixed $taxable): void + { + if (! $taxable->taxes()->exists()) { + return; + } + + $taxable->taxes->each(function ($tax) use ($taxable): void { + $tax->update([ + 'exchange_rate' => $taxable->exchange_rate, + 'base_amount' => $tax->amount * $taxable->exchange_rate, + ]); + }); + } +} diff --git a/app/Adapters/Purchases/MediaLibraryExpenseReceiptManager.php b/app/Adapters/Purchases/MediaLibraryExpenseReceiptManager.php new file mode 100644 index 00000000..aea4301e --- /dev/null +++ b/app/Adapters/Purchases/MediaLibraryExpenseReceiptManager.php @@ -0,0 +1,57 @@ +addMedia($receipt->path) + ->usingFileName($receipt->fileName) + ->toMediaCollection(self::COLLECTION); + } + + public function replace(Expense $expense, PendingExpenseReceipt $receipt): void + { + $this->clear($expense); + $this->attach($expense, $receipt); + } + + public function attachBase64( + Expense $expense, + string $contents, + string $fileName, + bool $replaceExisting, + ): void { + if ($replaceExisting) { + $this->clear($expense); + } + + $expense->addMediaFromBase64($contents) + ->usingFileName($fileName) + ->toMediaCollection(self::COLLECTION); + } + + public function clear(Expense $expense): void + { + $expense->clearMediaCollection(self::COLLECTION); + } + + public function first(Expense $expense): ?StoredExpenseReceipt + { + $media = $expense->getFirstMedia(self::COLLECTION); + + if (! $media) { + return null; + } + + return new StoredExpenseReceipt($media->getPath(), $media->file_name); + } +} diff --git a/app/Adapters/Purchases/MoneyExpenseExchangeRateRecorder.php b/app/Adapters/Purchases/MoneyExpenseExchangeRateRecorder.php new file mode 100644 index 00000000..97029078 --- /dev/null +++ b/app/Adapters/Purchases/MoneyExpenseExchangeRateRecorder.php @@ -0,0 +1,15 @@ +clear($expense); + + if ($taxes === []) { + return; + } + + $taxTypes = TaxType::query() + ->where('company_id', $expense->company_id) + ->where('type', TaxType::TYPE_GENERAL) + ->whereTransactionType(TaxType::TRANSACTION_TYPE_PURCHASES) + ->whereIn('id', collect($taxes)->pluck('tax_type_id')) + ->get() + ->keyBy('id'); + + foreach ($taxes as $tax) { + $taxType = $taxTypes->get($tax['tax_type_id']); + + $expense->taxes()->create([ + 'tax_type_id' => $taxType->id, + 'company_id' => $expense->company_id, + 'currency_id' => $expense->currency_id, + 'exchange_rate' => $expense->exchange_rate, + 'amount' => (int) $tax['amount'], + 'base_amount' => (int) round($tax['amount'] * $expense->exchange_rate), + 'name' => $taxType->name, + 'percent' => $taxType->percent, + 'fixed_amount' => $taxType->fixed_amount, + 'calculation_type' => $taxType->calculation_type, + 'compound_tax' => $taxType->compound_tax, + ]); + } + } + + public function clear(Expense $expense): void + { + $expense->taxes()->delete(); + } +} diff --git a/app/Adapters/Receivables/LaravelPaymentEmailSender.php b/app/Adapters/Receivables/LaravelPaymentEmailSender.php new file mode 100644 index 00000000..285b6d5f --- /dev/null +++ b/app/Adapters/Receivables/LaravelPaymentEmailSender.php @@ -0,0 +1,25 @@ +cc($data['cc']); + } + + if (! empty($data['bcc'])) { + $mail->bcc($data['bcc']); + } + + $mail->send(new SendPaymentMail($data)); + } +} diff --git a/app/Adapters/Receivables/MoneyPaymentExchangeRateRecorder.php b/app/Adapters/Receivables/MoneyPaymentExchangeRateRecorder.php new file mode 100644 index 00000000..c6ee1204 --- /dev/null +++ b/app/Adapters/Receivables/MoneyPaymentExchangeRateRecorder.php @@ -0,0 +1,15 @@ +invoiceBalanceService->creditedTotal($invoice); + } + + public function recalculate(Invoice $invoice): void + { + $this->invoiceBalanceService->recalculate($invoice); + } +} diff --git a/app/Adapters/Receivables/SalesPaymentNumberAssigner.php b/app/Adapters/Receivables/SalesPaymentNumberAssigner.php new file mode 100644 index 00000000..e1b7ac55 --- /dev/null +++ b/app/Adapters/Receivables/SalesPaymentNumberAssigner.php @@ -0,0 +1,38 @@ +setModel($payment) + ->setCompany($companyId) + ->setCustomer($customerId) + ->setModelObject($payment->getKey()); + + $number = null; + + if ($generateNumber) { + $number = $serial->getNextNumber(); + } else { + $serial->setNextNumbers(); + } + + return new PaymentNumberAssignment( + $number, + (int) $serial->nextSequenceNumber, + (int) $serial->nextCustomerSequenceNumber, + ); + } +} diff --git a/app/Adapters/Sales/LaravelEstimateEmailSender.php b/app/Adapters/Sales/LaravelEstimateEmailSender.php new file mode 100644 index 00000000..60a9a8c8 --- /dev/null +++ b/app/Adapters/Sales/LaravelEstimateEmailSender.php @@ -0,0 +1,25 @@ +cc($data['cc']); + } + + if (! empty($data['bcc'])) { + $mail->bcc($data['bcc']); + } + + $mail->send(new SendEstimateMail($data)); + } +} diff --git a/app/Adapters/Sales/LaravelInvoiceEmailSender.php b/app/Adapters/Sales/LaravelInvoiceEmailSender.php new file mode 100644 index 00000000..ab201f4d --- /dev/null +++ b/app/Adapters/Sales/LaravelInvoiceEmailSender.php @@ -0,0 +1,28 @@ +cc($data['cc']); + } + + if (! empty($data['bcc'])) { + $mail->bcc($data['bcc']); + } + + $mail->send($creditNote + ? new SendCreditNoteMail($data) + : new SendInvoiceMail($data)); + } +} diff --git a/app/Adapters/Sales/MoneyDocumentExchangeRateRecorder.php b/app/Adapters/Sales/MoneyDocumentExchangeRateRecorder.php new file mode 100644 index 00000000..40e7171b --- /dev/null +++ b/app/Adapters/Sales/MoneyDocumentExchangeRateRecorder.php @@ -0,0 +1,15 @@ +app->bind(CompanyAddressWriter::class, EloquentCompanyAddressWriter::class); + $this->app->bind(CompanyDataPurger::class, EloquentCompanyDataPurger::class); + $this->app->bind(CompanyDefaultsProvisioner::class, EloquentBusinessDefaultsProvisioner::class); + $this->app->bind(CompanyInvitationSender::class, LaravelCompanyInvitationSender::class); + $this->app->bind(CompanyLogoManager::class, MediaLibraryCompanyLogoManager::class); + $this->app->bind(MemberReferencesCleaner::class, EloquentMemberReferencesCleaner::class); + $this->app->bind(UserAvatarManager::class, MediaLibraryUserAvatarManager::class); + } + + public function boot(): void + { + Gate::policy(Company::class, CompanyPolicy::class); + Gate::policy(User::class, UserPolicy::class); + Gate::policy(Role::class, RolePolicy::class); + + Gate::define('create company', [CompanyPolicy::class, 'create']); + Gate::define('transfer company ownership', [CompanyPolicy::class, 'transferOwnership']); + Gate::define('delete company', [CompanyPolicy::class, 'delete']); + Gate::define('manage company', [SettingsPolicy::class, 'manageCompany']); + Gate::define('delete multiple users', [UserPolicy::class, 'deleteMultiple']); + Gate::define('owner only', [OwnerPolicy::class, 'managedByOwner']); + } +} diff --git a/app/Services/Company/CompanyService.php b/app/Domains/Accounts/Application/CompanyService.php similarity index 50% rename from app/Services/Company/CompanyService.php rename to app/Domains/Accounts/Application/CompanyService.php index b2c3e1d4..2713a09e 100644 --- a/app/Services/Company/CompanyService.php +++ b/app/Domains/Accounts/Application/CompanyService.php @@ -1,24 +1,26 @@ setupRoles($company); - $this->setupDefaultPaymentMethods($company); - $this->setupDefaultUnits($company); - $this->setupDefaultSettings($company); + $this->companyDefaultsProvisioner->provision($company); + $this->setupDefaultSettings($company, $currencyId); return true; } @@ -38,88 +40,9 @@ class CompanyService } } - public function delete(Company $company, User $user): bool + public function delete(Company $company): bool { - if ($company->exchangeRateLogs()->exists()) { - $company->exchangeRateLogs()->delete(); - } - - if ($company->exchangeRateProviders()->exists()) { - $company->exchangeRateProviders()->delete(); - } - - if ($company->expenses()->exists()) { - $company->expenses->each->delete(); - } - - if ($company->expenseCategories()->exists()) { - $company->expenseCategories()->delete(); - } - - if ($company->payments()->exists()) { - PaymentAllocation::query() - ->whereIn('payment_id', $company->payments()->select('id')) - ->delete(); - $company->payments()->delete(); - } - - if ($company->paymentMethods()->exists()) { - $company->paymentMethods()->delete(); - } - - if ($company->customFieldValues()->exists()) { - $company->customFieldValues()->delete(); - } - - if ($company->customFields()->exists()) { - $company->customFields()->delete(); - } - - if ($company->invoices()->exists()) { - $company->invoices->map(function ($invoice) { - $this->checkModelData($invoice); - - if ($invoice->transactions()->exists()) { - $invoice->transactions()->delete(); - } - }); - - $company->invoices()->delete(); - } - - if ($company->recurringInvoices()->exists()) { - $company->recurringInvoices->map(function ($recurringInvoice) { - $this->checkModelData($recurringInvoice); - }); - - $company->recurringInvoices()->delete(); - } - - if ($company->estimates()->exists()) { - $company->estimates->map(function ($estimate) { - $this->checkModelData($estimate); - }); - - $company->estimates()->delete(); - } - - if ($company->items()->exists()) { - $company->items()->delete(); - } - - if ($company->taxTypes()->exists()) { - $company->taxTypes()->delete(); - } - - if ($company->customers()->exists()) { - $company->customers->map(function ($customer) { - if ($customer->addresses()->exists()) { - $customer->addresses()->delete(); - } - - $customer->delete(); - }); - } + $this->companyDataPurger->purge($company); $roles = Role::when($company->id, function ($query) use ($company) { return $query->where('scope', $company->id); @@ -131,43 +54,15 @@ class CompanyService }); } - if ($company->users()->exists()) { - $user->companies()->detach($company->id); - } + $company->users()->detach(); $company->settings()->delete(); - - $company->address()->delete(); - $company->delete(); return true; } - private function setupDefaultPaymentMethods(Company $company): void - { - PaymentMethod::create(['name' => 'Cash', 'company_id' => $company->id]); - PaymentMethod::create(['name' => 'Check', 'company_id' => $company->id]); - PaymentMethod::create(['name' => 'Credit Card', 'company_id' => $company->id]); - PaymentMethod::create(['name' => 'Bank Transfer', 'company_id' => $company->id]); - } - - private function setupDefaultUnits(Company $company): void - { - Unit::create(['name' => 'box', 'company_id' => $company->id]); - Unit::create(['name' => 'cm', 'company_id' => $company->id]); - Unit::create(['name' => 'dz', 'company_id' => $company->id]); - Unit::create(['name' => 'ft', 'company_id' => $company->id]); - Unit::create(['name' => 'g', 'company_id' => $company->id]); - Unit::create(['name' => 'in', 'company_id' => $company->id]); - Unit::create(['name' => 'kg', 'company_id' => $company->id]); - Unit::create(['name' => 'km', 'company_id' => $company->id]); - Unit::create(['name' => 'lb', 'company_id' => $company->id]); - Unit::create(['name' => 'mg', 'company_id' => $company->id]); - Unit::create(['name' => 'pc', 'company_id' => $company->id]); - } - - private function setupDefaultSettings(Company $company): void + private function setupDefaultSettings(Company $company, int $currencyId): void { $defaultInvoiceEmailBody = 'You have received a new invoice from {COMPANY_NAME}.
Please download using the button below:'; $defaultEstimateEmailBody = 'You have received a new estimate from {COMPANY_NAME}.
Please download using the button below:'; @@ -189,7 +84,7 @@ class CompanyService 'estimate_billing_address_format' => $billingAddressFormat, 'payment_company_address_format' => $companyAddressFormat, 'payment_from_customer_address_format' => $paymentFromCustomerAddress, - 'currency' => request()->currency ?? 13, + 'currency' => $currencyId, 'time_zone' => 'Asia/Kolkata', 'language' => 'en', 'fiscal_year' => '1-12', @@ -223,19 +118,4 @@ class CompanyService CompanySetting::setSettings($settings, $company->id); } - - private function checkModelData($model): void - { - $model->items->map(function ($item) { - if ($item->taxes()->exists()) { - $item->taxes()->delete(); - } - - $item->delete(); - }); - - if ($model->taxes()->exists()) { - $model->taxes()->delete(); - } - } } diff --git a/app/Services/Company/InvitationService.php b/app/Domains/Accounts/Application/InvitationService.php similarity index 89% rename from app/Services/Company/InvitationService.php rename to app/Domains/Accounts/Application/InvitationService.php index cf7eac7f..1eab76e4 100644 --- a/app/Services/Company/InvitationService.php +++ b/app/Domains/Accounts/Application/InvitationService.php @@ -1,14 +1,13 @@ load(['company', 'role', 'invitedBy']); try { - Mail::to($email)->send(new CompanyInvitationMail($invitation)); + $this->companyInvitationSender->send($invitation); } catch (\Exception $e) { \Log::warning('Failed to send invitation email to '.$email.': '.$e->getMessage()); } diff --git a/app/Domains/Accounts/Application/MemberService.php b/app/Domains/Accounts/Application/MemberService.php new file mode 100644 index 00000000..c0924f8c --- /dev/null +++ b/app/Domains/Accounts/Application/MemberService.php @@ -0,0 +1,79 @@ + $attributes + * @param iterable $companies + */ + public function create(array $attributes, iterable $companies): User + { + $user = User::create($attributes); + + $user->setSettings([ + 'language' => 'default', + ]); + + $companies = collect($companies); + $user->companies()->sync($companies->pluck('id')); + + foreach ($companies as $company) { + BouncerFacade::scope()->to($company['id']); + + BouncerFacade::sync($user)->roles([$company['role']]); + } + + return $user; + } + + /** + * @param array $attributes + * @param iterable $companies + */ + public function update(User $user, array $attributes, iterable $companies): User + { + $user->update($attributes); + + $companies = collect($companies); + $user->companies()->sync($companies->pluck('id')); + + foreach ($companies as $company) { + BouncerFacade::scope()->to($company['id']); + + BouncerFacade::sync($user)->roles([$company['role']]); + } + + return $user; + } + + public function delete(array $ids): bool + { + foreach ($ids as $id) { + $user = User::find($id); + + if (! $user) { + continue; + } + + $this->memberReferencesCleaner->clear($user); + + if ($user->settings()->exists()) { + $user->settings()->delete(); + } + + $user->delete(); + } + + return true; + } +} diff --git a/app/Domains/Accounts/Contracts/CompanyAddressWriter.php b/app/Domains/Accounts/Contracts/CompanyAddressWriter.php new file mode 100644 index 00000000..57a671fd --- /dev/null +++ b/app/Domains/Accounts/Contracts/CompanyAddressWriter.php @@ -0,0 +1,11 @@ + $attributes */ + public function upsert(Company $company, array $attributes): void; +} diff --git a/app/Domains/Accounts/Contracts/CompanyDataPurger.php b/app/Domains/Accounts/Contracts/CompanyDataPurger.php new file mode 100644 index 00000000..72e2ccad --- /dev/null +++ b/app/Domains/Accounts/Contracts/CompanyDataPurger.php @@ -0,0 +1,10 @@ +has('address')) { - $company->address()->updateOrCreate( - ['company_id' => $company->id], - $request->address, - ); + $this->companyAddressWriter->upsert($company, $request->validated('address')); } $company->load(['owner', 'address']); @@ -70,16 +70,16 @@ class CompaniesController extends Controller $user = $request->user(); $company = Company::create($request->getCompanyPayload()); - $company->unique_hash = Hashids::connection(Company::class)->encode($company->id); + $company->unique_hash = Hashids::connection(HashidConnection::Company->value)->encode($company->id); $company->save(); - $this->companyService->setupDefaults($company); + $this->companyService->setupDefaults($company, (int) $request->validated('currency')); $user->companies()->attach($company->id); BouncerFacade::scope()->to($company->id); $user->assign('owner'); if ($request->address) { - $company->address()->create($request->address); + $this->companyAddressWriter->upsert($company, $request->validated('address')); } return new CompanyResource($company); @@ -91,13 +91,11 @@ class CompaniesController extends Controller $this->authorize('delete company', $company); - $user = $request->user(); - if ($request->name !== $company->name) { return respondJson('company_name_must_match_with_given_name', 'Company name must match with given name'); } - $this->companyService->delete($company, $user); + $this->companyService->delete($company); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Admin/UsersController.php b/app/Domains/Accounts/Http/Controllers/Admin/UsersController.php similarity index 89% rename from app/Http/Controllers/Admin/UsersController.php rename to app/Domains/Accounts/Http/Controllers/Admin/UsersController.php index 396a3109..661f7384 100644 --- a/app/Http/Controllers/Admin/UsersController.php +++ b/app/Domains/Accounts/Http/Controllers/Admin/UsersController.php @@ -1,12 +1,12 @@ middleware('guest')->except('logout'); } + + public function logout(Request $request): void + { + auth()->guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } } diff --git a/app/Http/Controllers/Company/Auth/ResetPasswordController.php b/app/Domains/Accounts/Http/Controllers/Auth/ResetPasswordController.php similarity index 95% rename from app/Http/Controllers/Company/Auth/ResetPasswordController.php rename to app/Domains/Accounts/Http/Controllers/Auth/ResetPasswordController.php index cb5bdc83..4a01a014 100644 --- a/app/Http/Controllers/Company/Auth/ResetPasswordController.php +++ b/app/Domains/Accounts/Http/Controllers/Auth/ResetPasswordController.php @@ -1,8 +1,8 @@ header('company')); + + $this->authorize('manage company', $company); + + $company->update($request->getCompanyPayload()); + + $this->companyAddressWriter->upsert($company, (array) $request->input('address')); + + return new CompanyResource($company); + } + + public function uploadCompanyLogo(CompanyLogoRequest $request) + { + $company = Company::find($request->header('company')); + + $this->authorize('manage company', $company); + + $data = json_decode($request->company_logo); + + if (isset($request->is_company_logo_removed) && (bool) $request->is_company_logo_removed) { + $this->companyLogoManager->clear($company); + } + if ($data) { + $this->companyLogoManager->replaceBase64($company, $data->data, $data->name); + } + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Http/Controllers/Company/Settings/CompanySettingsController.php b/app/Domains/Accounts/Http/Controllers/Company/CompanySettingsController.php similarity index 86% rename from app/Http/Controllers/Company/Settings/CompanySettingsController.php rename to app/Domains/Accounts/Http/Controllers/Company/CompanySettingsController.php index 4f8507ad..45fbe390 100644 --- a/app/Http/Controllers/Company/Settings/CompanySettingsController.php +++ b/app/Domains/Accounts/Http/Controllers/Company/CompanySettingsController.php @@ -1,13 +1,13 @@ authorize('create', User::class); - $user = $this->memberService->create($request); + $user = $this->memberService->create( + $request->getUserPayload(), + $request->validated('companies'), + ); return new UserResource($user); } @@ -77,7 +80,11 @@ class MembersController extends Controller { $this->authorize('update', $member); - $this->memberService->update($member, $request); + $this->memberService->update( + $member, + $request->getUserPayload(), + $request->validated('companies'), + ); return new UserResource($member); } diff --git a/app/Http/Controllers/Company/Role/RolesController.php b/app/Domains/Accounts/Http/Controllers/Company/RolesController.php similarity index 92% rename from app/Http/Controllers/Company/Role/RolesController.php rename to app/Domains/Accounts/Http/Controllers/Company/RolesController.php index ba88e418..657cb592 100644 --- a/app/Http/Controllers/Company/Role/RolesController.php +++ b/app/Domains/Accounts/Http/Controllers/Company/RolesController.php @@ -1,11 +1,11 @@ user()); @@ -32,22 +37,20 @@ class UserProfileController extends Controller $user = auth()->user(); if (isset($request->is_admin_avatar_removed) && (bool) $request->is_admin_avatar_removed) { - $user->clearMediaCollection('admin_avatar'); + $this->userAvatarManager->clear($user); } if ($user && $request->hasFile('admin_avatar')) { - $user->clearMediaCollection('admin_avatar'); - - $user->addMediaFromRequest('admin_avatar') - ->toMediaCollection('admin_avatar'); + $file = $request->file('admin_avatar'); + $this->userAvatarManager->replaceFile( + $user, + $file->getRealPath(), + $file->getClientOriginalName(), + ); } if ($user && $request->has('avatar')) { $data = json_decode($request->avatar); - $user->clearMediaCollection('admin_avatar'); - - $user->addMediaFromBase64($data->data) - ->usingFileName($data->name) - ->toMediaCollection('admin_avatar'); + $this->userAvatarManager->replaceBase64($user, $data->data, $data->name); } return new UserResource($user); diff --git a/app/Domains/Accounts/Http/Controllers/InvitationDeclineController.php b/app/Domains/Accounts/Http/Controllers/InvitationDeclineController.php new file mode 100644 index 00000000..b5cebd7d --- /dev/null +++ b/app/Domains/Accounts/Http/Controllers/InvitationDeclineController.php @@ -0,0 +1,26 @@ +where('token', $token) + ->pending() + ->first(); + + if (! $invitation) { + return view('app')->with(['message' => 'Invitation not found or already expired.']); + } + + $invitation->update(['status' => CompanyInvitation::STATUS_DECLINED]); + + return view('app')->with(['message' => 'Invitation declined.']); + } +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Domains/Accounts/Http/Middleware/Authenticate.php similarity index 90% rename from app/Http/Middleware/Authenticate.php rename to app/Domains/Accounts/Http/Middleware/Authenticate.php index 59537904..70289077 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Domains/Accounts/Http/Middleware/Authenticate.php @@ -1,6 +1,6 @@ validated()) + ->only([ + 'name', + 'email', + 'phone', + 'password', + ]) ->merge([ 'creator_id' => $this->user()->id, ]) diff --git a/app/Http/Requests/ProfileRequest.php b/app/Domains/Accounts/Http/Requests/ProfileRequest.php similarity index 94% rename from app/Http/Requests/ProfileRequest.php rename to app/Domains/Accounts/Http/Requests/ProfileRequest.php index 502892fb..a78a4adb 100644 --- a/app/Http/Requests/ProfileRequest.php +++ b/app/Domains/Accounts/Http/Requests/ProfileRequest.php @@ -1,6 +1,6 @@ join('roles', 'roles.id', '=', 'assigned_roles.role_id') ->where('assigned_roles.entity_id', $user->id) - ->where('assigned_roles.entity_type', get_class($user)) + ->where('assigned_roles.entity_type', $user->getMorphClass()) ->where('assigned_roles.scope', $this->id) ->value('roles.title'); } diff --git a/app/Http/Resources/Customer/CompanyResource.php b/app/Domains/Accounts/Http/Resources/CustomerPortal/CompanyResource.php similarity index 84% rename from app/Http/Resources/Customer/CompanyResource.php rename to app/Domains/Accounts/Http/Resources/CustomerPortal/CompanyResource.php index f4d734e4..4d6be0fe 100644 --- a/app/Http/Resources/Customer/CompanyResource.php +++ b/app/Domains/Accounts/Http/Resources/CustomerPortal/CompanyResource.php @@ -1,7 +1,8 @@ id == $company->owner_id) { + return true; + } + + return false; + } +} diff --git a/app/Policies/UserPolicy.php b/app/Domains/Accounts/Policies/UserPolicy.php similarity index 97% rename from app/Policies/UserPolicy.php rename to app/Domains/Accounts/Policies/UserPolicy.php index 1c64c46b..e8f7edff 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Domains/Accounts/Policies/UserPolicy.php @@ -1,8 +1,8 @@ only(['index', 'store', 'destroy']); + +Route::get('/me', [UserProfileController::class, 'show']); +Route::put('/me', [UserProfileController::class, 'update']); +Route::get('/me/settings', [UserProfileController::class, 'showSettings']); +Route::put('/me/settings', [UserProfileController::class, 'updateSettings']); +Route::post('/me/upload-avatar', [UserProfileController::class, 'uploadAvatar']); + +Route::put('/company', [CompanyController::class, 'updateCompany']); +Route::post('/company/upload-logo', [CompanyController::class, 'uploadCompanyLogo']); +Route::get('/company/settings', [CompanySettingsController::class, 'show']); +Route::post('/company/settings', [CompanySettingsController::class, 'update']); +Route::get('/company/has-transactions', [CompanySettingsController::class, 'checkTransactions']); + +Route::get('abilities', AbilitiesController::class); +Route::apiResource('roles', RolesController::class); diff --git a/app/Domains/Accounts/routes/impersonation.php b/app/Domains/Accounts/routes/impersonation.php new file mode 100644 index 00000000..707a3bbe --- /dev/null +++ b/app/Domains/Accounts/routes/impersonation.php @@ -0,0 +1,6 @@ +group(function (): void { + Route::post('login', [AuthController::class, 'login']); + Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); + Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:10,2'); + Route::post('reset/password', [ResetPasswordController::class, 'reset']); +}); + +Route::get('/invitations/{token}/details', [InvitationRegistrationController::class, 'details']); +Route::post('/auth/register-with-invitation', [InvitationRegistrationController::class, 'register']); diff --git a/app/Domains/Accounts/routes/web.php b/app/Domains/Accounts/routes/web.php new file mode 100644 index 00000000..624f9572 --- /dev/null +++ b/app/Domains/Accounts/routes/web.php @@ -0,0 +1,9 @@ + $attributes + * @param array> $taxes + */ + public function create(array $attributes, array $taxes, int $companyId, int $creatorId): Item + { + $attributes['company_id'] = $companyId; + $attributes['creator_id'] = $creatorId; + $attributes['currency_id'] = CompanySetting::getSetting('currency', $companyId); + + $item = Item::create($attributes); + + $this->itemTaxManager->attach($item, $taxes, $companyId); + + return Item::query()->with('taxes')->findOrFail($item->getKey()); + } + + /** + * @param array $attributes + * @param array> $taxes + */ + public function update(Item $item, array $attributes, array $taxes, int $companyId): Item + { + $item->update($attributes); + + $this->itemTaxManager->replace($item, $taxes, $companyId); + + return Item::query()->with('taxes')->findOrFail($item->getKey()); + } +} diff --git a/app/Domains/Catalog/CatalogServiceProvider.php b/app/Domains/Catalog/CatalogServiceProvider.php new file mode 100644 index 00000000..abf98ad0 --- /dev/null +++ b/app/Domains/Catalog/CatalogServiceProvider.php @@ -0,0 +1,27 @@ +app->bind(ItemTaxManager::class, TaxationItemTaxManager::class); + } + + public function boot(): void + { + Gate::policy(Item::class, ItemPolicy::class); + Gate::policy(Unit::class, UnitPolicy::class); + Gate::define('delete multiple items', [ItemPolicy::class, 'deleteMultiple']); + } +} diff --git a/app/Domains/Catalog/Contracts/ItemTaxManager.php b/app/Domains/Catalog/Contracts/ItemTaxManager.php new file mode 100644 index 00000000..34bea1df --- /dev/null +++ b/app/Domains/Catalog/Contracts/ItemTaxManager.php @@ -0,0 +1,14 @@ +> $taxes */ + public function attach(Item $item, array $taxes, int $companyId): void; + + /** @param array> $taxes */ + public function replace(Item $item, array $taxes, int $companyId): void; +} diff --git a/app/Http/Controllers/Company/Item/ItemsController.php b/app/Domains/Catalog/Http/Controllers/ItemsController.php similarity index 69% rename from app/Http/Controllers/Company/Item/ItemsController.php rename to app/Domains/Catalog/Http/Controllers/ItemsController.php index 380aa956..50187f94 100644 --- a/app/Http/Controllers/Company/Item/ItemsController.php +++ b/app/Domains/Catalog/Http/Controllers/ItemsController.php @@ -1,14 +1,14 @@ authorize('create', Item::class); - $item = $this->itemService->create($request); + $item = $this->itemService->create( + $request->validated(), + $request->input('taxes', []), + (int) $request->header('company'), + (int) $request->user()->getAuthIdentifier(), + ); return new ItemResource($item); } @@ -76,14 +80,18 @@ class ItemsController extends Controller /** * Update an existing Item. * - * @param App\Http\Requests\ItemsRequest $request * @return JsonResponse */ - public function update(Requests\ItemsRequest $request, Item $item) + public function update(ItemsRequest $request, Item $item) { $this->authorize('update', $item); - $item = $this->itemService->update($item, $request); + $item = $this->itemService->update( + $item, + $request->validated(), + $request->input('taxes', []), + (int) $request->header('company'), + ); return new ItemResource($item); } diff --git a/app/Http/Controllers/Company/Item/UnitsController.php b/app/Domains/Catalog/Http/Controllers/UnitsController.php similarity index 89% rename from app/Http/Controllers/Company/Item/UnitsController.php rename to app/Domains/Catalog/Http/Controllers/UnitsController.php index 8fb3c762..07df1285 100644 --- a/app/Http/Controllers/Company/Item/UnitsController.php +++ b/app/Domains/Catalog/Http/Controllers/UnitsController.php @@ -1,11 +1,11 @@ $attributes + * @param array|null $shippingAddress + * @param array|null $billingAddress + */ + public function create( + array $attributes, + ?array $shippingAddress = null, + ?array $billingAddress = null, + ?iterable $customFields = null, + ): Customer { + $customer = DB::transaction(function () use ($attributes, $shippingAddress, $billingAddress, $customFields): Customer { + $customer = Customer::create($attributes); + + $this->replaceAddresses($customer, $shippingAddress, $billingAddress); + + if ($customFields) { + $this->customFieldValueWriter->attach($customer, $customFields); + } + + return $customer; + }); + + return Customer::with('billingAddress', 'shippingAddress', 'fields')->findOrFail($customer->id); + } + + /** + * @param array $attributes + * @param array|null $shippingAddress + * @param array|null $billingAddress + * + * @throws ValidationException + */ + public function update( + Customer $customer, + array $attributes, + ?array $shippingAddress = null, + ?array $billingAddress = null, + ?iterable $customFields = null, + ): Customer { + $hasCurrencyLockedActivity = $customer->estimates()->exists() + || $customer->invoices()->exists() + || $customer->payments()->exists() + || $customer->recurringInvoices()->exists(); + + if (($customer->currency_id !== ($attributes['currency_id'] ?? null)) && $hasCurrencyLockedActivity) { + throw ValidationException::withMessages([ + 'currency_id' => ['you_cannot_edit_currency'], + ]); + } + + DB::transaction(function () use ($customer, $attributes, $shippingAddress, $billingAddress, $customFields): void { + $customer->update($attributes); + $customer->addresses()->delete(); + $this->replaceAddresses($customer, $shippingAddress, $billingAddress); + + if ($customFields) { + $this->customFieldValueWriter->update($customer, $customFields); + } + }); + + return $customer->fresh(['billingAddress', 'shippingAddress', 'fields']); + } + + /** + * @param array $attributes + * @param array|null $shippingAddress + * @param array|null $billingAddress + */ + public function updateProfile( + Customer $customer, + array $attributes, + ?array $shippingAddress = null, + ?array $billingAddress = null, + ): Customer { + DB::transaction(function () use ($customer, $attributes, $shippingAddress, $billingAddress): void { + $customer->update($attributes); + + if ($shippingAddress !== null) { + $customer->shippingAddress()->delete(); + $customer->addresses()->create($shippingAddress); + } + + if ($billingAddress !== null) { + $customer->billingAddress()->delete(); + $customer->addresses()->create($billingAddress); + } + }); + + return $customer->fresh(['billingAddress', 'shippingAddress', 'fields']); + } + + /** @param iterable $ids */ + public function delete(iterable $ids): bool + { + DB::transaction(function () use ($ids): void { + foreach ($ids as $id) { + $customer = Customer::find($id); + + if (! $customer) { + continue; + } + + $this->customerDataPurger->purge($customer); + $customer->addresses()->delete(); + $customer->delete(); + } + }); + + return true; + } + + /** + * @param array|null $shippingAddress + * @param array|null $billingAddress + */ + private function replaceAddresses(Customer $customer, ?array $shippingAddress, ?array $billingAddress): void + { + if ($shippingAddress !== null) { + $customer->addresses()->create($shippingAddress); + } + + if ($billingAddress !== null) { + $customer->addresses()->create($billingAddress); + } + } +} diff --git a/app/Domains/Contacts/ContactsServiceProvider.php b/app/Domains/Contacts/ContactsServiceProvider.php new file mode 100644 index 00000000..6d244a64 --- /dev/null +++ b/app/Domains/Contacts/ContactsServiceProvider.php @@ -0,0 +1,33 @@ +app->bind(CustomerAvatarManager::class, MediaLibraryCustomerAvatarManager::class); + $this->app->bind(CustomerDataPurger::class, EloquentCustomerDataPurger::class); + $this->app->bind(CustomerPortalDashboardProvider::class, EloquentCustomerPortalDashboardProvider::class); + $this->app->bind(CustomerStatsProvider::class, EloquentCustomerStatsProvider::class); + } + + public function boot(): void + { + Gate::policy(Customer::class, CustomerPolicy::class); + Gate::define('delete multiple customers', [CustomerPolicy::class, 'deleteMultiple']); + } +} diff --git a/app/Domains/Contacts/Contracts/CustomerAvatarManager.php b/app/Domains/Contacts/Contracts/CustomerAvatarManager.php new file mode 100644 index 00000000..3cfbc1a0 --- /dev/null +++ b/app/Domains/Contacts/Contracts/CustomerAvatarManager.php @@ -0,0 +1,12 @@ + */ + public function get(Customer $customer): array; +} diff --git a/app/Domains/Contacts/Contracts/CustomerStatsProvider.php b/app/Domains/Contacts/Contracts/CustomerStatsProvider.php new file mode 100644 index 00000000..4c861b7a --- /dev/null +++ b/app/Domains/Contacts/Contracts/CustomerStatsProvider.php @@ -0,0 +1,11 @@ + */ + public function get(Customer $customer, int $companyId, bool $previousYear = false): array; +} diff --git a/app/Domains/Contacts/Http/Controllers/Company/CustomerStatsController.php b/app/Domains/Contacts/Http/Controllers/Company/CustomerStatsController.php new file mode 100644 index 00000000..f42a365c --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/Company/CustomerStatsController.php @@ -0,0 +1,37 @@ +authorize('view', $customer); + + $chartData = $this->customerStatsProvider->get( + $customer, + $request->header('company'), + $request->has('previous_year') + ); + + $customer = Customer::find($customer->id); + $this->customerStatementQuery->hydrateAccountSummaries([$customer]); + + return (new CustomerResource($customer)) + ->additional(['meta' => [ + 'chartData' => $chartData, + ]]); + } +} diff --git a/app/Http/Controllers/Company/Customer/CustomersController.php b/app/Domains/Contacts/Http/Controllers/Company/CustomersController.php similarity index 60% rename from app/Http/Controllers/Company/Customer/CustomersController.php rename to app/Domains/Contacts/Http/Controllers/Company/CustomersController.php index 3caa4240..41cf9c00 100644 --- a/app/Http/Controllers/Company/Customer/CustomersController.php +++ b/app/Domains/Contacts/Http/Controllers/Company/CustomersController.php @@ -1,14 +1,14 @@ applyFilters($request->all()) ->paginateData($limit); - $this->customerStatementService->hydrateAccountSummaries( + $this->customerStatementQuery->hydrateAccountSummaries( $customers instanceof LengthAwarePaginator ? $customers->getCollection() : $customers ); @@ -52,12 +52,17 @@ class CustomersController extends Controller * @param Request $request * @return JsonResponse */ - public function store(Requests\CustomerRequest $request) + public function store(CustomerRequest $request) { $this->authorize('create', Customer::class); - $customer = $this->customerService->create($request); - $this->customerStatementService->hydrateAccountSummaries([$customer]); + $customer = $this->customerService->create( + attributes: $request->customerAttributes(), + shippingAddress: $request->shippingAddress(), + billingAddress: $request->billingAddress(), + customFields: $request->customFields(), + ); + $this->customerStatementQuery->hydrateAccountSummaries([$customer]); return new CustomerResource($customer); } @@ -71,7 +76,7 @@ class CustomersController extends Controller { $this->authorize('view', $customer); - $this->customerStatementService->hydrateAccountSummaries([$customer]); + $this->customerStatementQuery->hydrateAccountSummaries([$customer]); return new CustomerResource($customer); } @@ -82,12 +87,18 @@ class CustomersController extends Controller * @param Request $request * @return JsonResponse */ - public function update(Requests\CustomerRequest $request, Customer $customer) + public function update(CustomerRequest $request, Customer $customer) { $this->authorize('update', $customer); - $customer = $this->customerService->update($request, $customer); - $this->customerStatementService->hydrateAccountSummaries([$customer]); + $customer = $this->customerService->update( + customer: $customer, + attributes: $request->customerAttributes(), + shippingAddress: $request->shippingAddress(), + billingAddress: $request->billingAddress(), + customFields: $request->customFields(), + ); + $this->customerStatementQuery->hydrateAccountSummaries([$customer]); return new CustomerResource($customer); } diff --git a/app/Http/Controllers/Admin/CountriesController.php b/app/Domains/Contacts/Http/Controllers/CountriesController.php similarity index 66% rename from app/Http/Controllers/Admin/CountriesController.php rename to app/Domains/Contacts/Http/Controllers/CountriesController.php index c01da8c9..2d3498fe 100644 --- a/app/Http/Controllers/Admin/CountriesController.php +++ b/app/Domains/Contacts/Http/Controllers/CountriesController.php @@ -1,10 +1,10 @@ logout(); + } +} diff --git a/app/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php b/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php similarity index 95% rename from app/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php rename to app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php index 787d988c..2c12319a 100644 --- a/app/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php +++ b/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php @@ -1,8 +1,8 @@ json( + $this->customerPortalDashboardProvider->get(Auth::guard('customer')->user()) + ); + } +} diff --git a/app/Domains/Contacts/Http/Controllers/CustomerPortal/ProfileController.php b/app/Domains/Contacts/Http/Controllers/CustomerPortal/ProfileController.php new file mode 100644 index 00000000..a70b5d06 --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/CustomerPortal/ProfileController.php @@ -0,0 +1,55 @@ +user(); + + $customer = $this->customerService->updateProfile( + customer: $customer, + attributes: $request->customerAttributes(), + shippingAddress: $request->shippingAddress(), + billingAddress: $request->billingAddress(), + ); + + if ((bool) $request->validated('is_customer_avatar_removed', false)) { + $this->customerAvatarManager->clear($customer); + } + + $avatar = $request->file('customer_avatar'); + + if ($avatar) { + $this->customerAvatarManager->replace( + $customer, + $avatar->getPathname(), + $avatar->getClientOriginalName(), + ); + } + + return new CustomerResource($customer); + } + + public function getUser(Request $request) + { + $customer = Auth::guard('customer')->user(); + + return new CustomerResource($customer); + } +} diff --git a/app/Http/Middleware/CustomerGuest.php b/app/Domains/Contacts/Http/Middleware/CustomerGuest.php similarity index 91% rename from app/Http/Middleware/CustomerGuest.php rename to app/Domains/Contacts/Http/Middleware/CustomerGuest.php index 1dd20c33..c9ada608 100644 --- a/app/Http/Middleware/CustomerGuest.php +++ b/app/Domains/Contacts/Http/Middleware/CustomerGuest.php @@ -1,6 +1,6 @@ [ + 'nullable', + 'boolean', + ], ]; } - public function getShippingAddress() + /** @return array */ + public function customerAttributes(): array { - return collect($this->shipping) + return $this->safe()->only(['name', 'email', 'password']); + } + + /** @return array|null */ + public function shippingAddress(): ?array + { + $address = $this->input('shipping'); + + if (! is_array($address)) { + return null; + } + + return collect($address) ->merge([ 'type' => Address::SHIPPING_TYPE, ]) ->toArray(); } - public function getBillingAddress() + /** @return array|null */ + public function billingAddress(): ?array { - return collect($this->billing) + $address = $this->input('billing'); + + if (! is_array($address)) { + return null; + } + + return collect($address) ->merge([ 'type' => Address::BILLING_TYPE, ]) diff --git a/app/Http/Requests/CustomerRequest.php b/app/Domains/Contacts/Http/Requests/CustomerRequest.php similarity index 78% rename from app/Http/Requests/CustomerRequest.php rename to app/Domains/Contacts/Http/Requests/CustomerRequest.php index 7cc879cc..9600e1a0 100644 --- a/app/Http/Requests/CustomerRequest.php +++ b/app/Domains/Contacts/Http/Requests/CustomerRequest.php @@ -1,8 +1,8 @@ */ + public function customerAttributes(): array { return collect($this->validated()) ->only([ @@ -152,30 +153,48 @@ class CustomerRequest extends FormRequest ->toArray(); } - public function getShippingAddress() + /** @return array|null */ + public function shippingAddress(): ?array { - return collect($this->shipping) + $address = $this->input('shipping'); + + if (! is_array($address) || ! $this->hasAddress($address)) { + return null; + } + + return collect($address) ->merge([ 'type' => Address::SHIPPING_TYPE, ]) ->toArray(); } - public function getBillingAddress() + /** @return array|null */ + public function billingAddress(): ?array { - return collect($this->billing) + $address = $this->input('billing'); + + if (! is_array($address) || ! $this->hasAddress($address)) { + return null; + } + + return collect($address) ->merge([ 'type' => Address::BILLING_TYPE, ]) ->toArray(); } - public function hasAddress(array $address) + /** @return array|null */ + public function customFields(): ?array { - $data = Arr::where($address, function ($value, $key) { - return isset($value); - }); + $customFields = $this->input('customFields'); - return $data; + return is_array($customFields) && $customFields !== [] ? $customFields : null; + } + + private function hasAddress(array $address): bool + { + return Arr::where($address, fn ($value): bool => isset($value)) !== []; } } diff --git a/app/Http/Requests/DeleteCustomersRequest.php b/app/Domains/Contacts/Http/Requests/DeleteCustomersRequest.php similarity index 93% rename from app/Http/Requests/DeleteCustomersRequest.php rename to app/Domains/Contacts/Http/Requests/DeleteCustomersRequest.php index 3bc72431..99352868 100644 --- a/app/Http/Requests/DeleteCustomersRequest.php +++ b/app/Domains/Contacts/Http/Requests/DeleteCustomersRequest.php @@ -1,6 +1,6 @@ group(function (): void { + Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail']); + Route::post('reset/password', [ResetPasswordController::class, 'reset'])->name('customer.password.reset'); +}); diff --git a/app/Domains/Contacts/routes/customer.php b/app/Domains/Contacts/routes/customer.php new file mode 100644 index 00000000..f4ce4f48 --- /dev/null +++ b/app/Domains/Contacts/routes/customer.php @@ -0,0 +1,13 @@ + $attributes */ + public function create(array $attributes, mixed $defaultAnswer, int $companyId): CustomField + { + $attributes[getCustomFieldValueKey($attributes['type'])] = $defaultAnswer; + $attributes['company_id'] = $companyId; + $attributes['slug'] = clean_slug($attributes['model_type'], $attributes['name']); + + return CustomField::create($attributes); + } + + /** @param array $attributes */ + public function update(CustomField $customField, array $attributes, mixed $defaultAnswer): CustomField + { + $attributes[getCustomFieldValueKey($attributes['type'])] = $defaultAnswer; + $customField->update($attributes); + + return $customField; + } +} diff --git a/app/Domains/Metadata/Application/EloquentCustomFieldValueWriter.php b/app/Domains/Metadata/Application/EloquentCustomFieldValueWriter.php new file mode 100644 index 00000000..2926a2eb --- /dev/null +++ b/app/Domains/Metadata/Application/EloquentCustomFieldValueWriter.php @@ -0,0 +1,48 @@ +normalize($field); + $customField = CustomField::find($field['id']); + + $valuable->fields()->create([ + 'type' => $customField->type, + 'custom_field_id' => $customField->id, + 'company_id' => $customField->company_id, + getCustomFieldValueKey($customField->type) => $field['value'], + ]); + } + } + + public function update(Model $valuable, iterable $customFields): void + { + foreach ($customFields as $field) { + $field = $this->normalize($field); + $customField = CustomField::find($field['id']); + $customFieldValue = $valuable->fields()->firstOrCreate([ + 'custom_field_id' => $customField->id, + 'type' => $customField->type, + 'company_id' => $valuable->company_id, + ]); + + $type = getCustomFieldValueKey($customField->type); + $customFieldValue->$type = $field['value']; + $customFieldValue->save(); + } + } + + /** @return array{id: int, value: mixed} */ + private function normalize(mixed $field): array + { + return is_array($field) ? $field : (array) $field; + } +} diff --git a/app/Domains/Metadata/Concerns/HasCustomFields.php b/app/Domains/Metadata/Concerns/HasCustomFields.php new file mode 100644 index 00000000..72f5b785 --- /dev/null +++ b/app/Domains/Metadata/Concerns/HasCustomFields.php @@ -0,0 +1,43 @@ +morphMany(CustomFieldValue::class, 'custom_field_valuable'); + } + + protected static function booted() + { + static::deleting(function ($data) { + if ($data->fields()->exists()) { + $data->fields()->delete(); + } + }); + } + + public function getCustomFieldBySlug($slug) + { + return $this->fields() + ->with('customField') + ->whereHas('customField', function ($query) use ($slug) { + $query->where('slug', $slug); + })->first(); + } + + public function getCustomFieldValueBySlug($slug) + { + $value = $this->getCustomFieldBySlug($slug); + + if ($value) { + return $value->defaultAnswer; + } + + return null; + } +} diff --git a/app/Domains/Metadata/Contracts/CustomFieldValueWriter.php b/app/Domains/Metadata/Contracts/CustomFieldValueWriter.php new file mode 100644 index 00000000..f5e97600 --- /dev/null +++ b/app/Domains/Metadata/Contracts/CustomFieldValueWriter.php @@ -0,0 +1,12 @@ +authorize('create', CustomField::class); - $customField = $this->customFieldService->create($request); + $customField = $this->customFieldService->create( + $request->validated(), + $request->input('default_answer'), + (int) $request->header('company'), + ); return new CustomFieldResource($customField); } @@ -74,7 +78,11 @@ class CustomFieldsController extends Controller { $this->authorize('update', $customField); - $this->customFieldService->update($customField, $request); + $this->customFieldService->update( + $customField, + $request->validated(), + $request->input('default_answer'), + ); return new CustomFieldResource($customField); } diff --git a/app/Http/Controllers/Company/General/NotesController.php b/app/Domains/Metadata/Http/Controllers/NotesController.php similarity index 90% rename from app/Http/Controllers/Company/General/NotesController.php rename to app/Domains/Metadata/Http/Controllers/NotesController.php index 3c217eb7..19ac37a5 100644 --- a/app/Http/Controllers/Company/General/NotesController.php +++ b/app/Domains/Metadata/Http/Controllers/NotesController.php @@ -1,11 +1,11 @@ $this->id, - 'custom_field_valuable_type' => $this->custom_field_valuable_type, + 'custom_field_valuable_type' => ModelIdentityMap::publicType($this->custom_field_valuable_type), 'custom_field_valuable_id' => $this->custom_field_valuable_id, 'type' => $this->type, 'boolean_answer' => $this->boolean_answer, diff --git a/app/Http/Resources/CustomFieldResource.php b/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldResource.php similarity index 90% rename from app/Http/Resources/CustomFieldResource.php rename to app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldResource.php index 51879ace..859a8b5d 100644 --- a/app/Http/Resources/CustomFieldResource.php +++ b/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldResource.php @@ -1,7 +1,8 @@ $this->id, - 'custom_field_valuable_type' => $this->custom_field_valuable_type, + 'custom_field_valuable_type' => ModelIdentityMap::publicType($this->custom_field_valuable_type), 'custom_field_valuable_id' => $this->custom_field_valuable_id, 'type' => $this->type, 'boolean_answer' => $this->boolean_answer, diff --git a/app/Http/Resources/NoteResource.php b/app/Domains/Metadata/Http/Resources/NoteResource.php similarity index 86% rename from app/Http/Resources/NoteResource.php rename to app/Domains/Metadata/Http/Resources/NoteResource.php index 0da7dbe8..f1aa32bc 100644 --- a/app/Http/Resources/NoteResource.php +++ b/app/Domains/Metadata/Http/Resources/NoteResource.php @@ -1,7 +1,8 @@ app->bind(CustomFieldValueWriter::class, EloquentCustomFieldValueWriter::class); + } + + public function boot(): void + { + Gate::policy(CustomField::class, CustomFieldPolicy::class); + Gate::policy(Note::class, NotePolicy::class); + Gate::define('manage notes', [NotePolicy::class, 'manageNotes']); + Gate::define('view notes', [NotePolicy::class, 'viewNotes']); + } +} diff --git a/app/Models/CustomField.php b/app/Domains/Metadata/Models/CustomField.php similarity index 95% rename from app/Models/CustomField.php rename to app/Domains/Metadata/Models/CustomField.php index 2e0e2fd2..3707e3ff 100644 --- a/app/Models/CustomField.php +++ b/app/Domains/Metadata/Models/CustomField.php @@ -1,7 +1,8 @@ $payload */ + public function create(array $payload): ExchangeRateProvider + { + return ExchangeRateProvider::create($payload); + } + + /** @param array $payload */ + public function update(ExchangeRateProvider $provider, array $payload): ExchangeRateProvider + { + $provider->update($payload); + + return $provider; + } + + /** + * @param array $currencies + * @return Collection + */ + public function checkActiveCurrencies(array $currencies): Collection + { + if (empty($currencies)) { + return new Collection; + } + + $query = ExchangeRateProvider::where('active', true); + + foreach ($currencies as $currency) { + $query->orWhere(function ($q) use ($currency) { + $q->where('active', true) + ->whereJsonContains('currencies', $currency); + }); + } + + return $query->get(); + } + + /** + * @param array $currencies + * @return Collection + */ + public function checkUpdateActiveCurrencies(ExchangeRateProvider $provider, array $currencies): Collection + { + if (empty($currencies)) { + return new Collection; + } + + $query = ExchangeRateProvider::where('id', '<>', $provider->id) + ->where('active', true); + + $query->where(function ($q) use ($currencies) { + foreach ($currencies as $currency) { + $q->orWhereJsonContains('currencies', $currency); + } + }); + + return $query->get(); + } + + /** @param array $configuration */ + public function validateProvider(array $configuration): array + { + return ExchangeRateDriverFactory::make( + $configuration['driver'], + $configuration['key'], + $configuration['driver_config'] ?? [], + )->validateConnection(); + } + + public function getExchangeRate( + string $driver, + string $apiKey, + array $driverConfig, + string $baseCurrency, + string $targetCurrency, + ): array { + return ExchangeRateDriverFactory::make($driver, $apiKey, $driverConfig) + ->getExchangeRate($baseCurrency, $targetCurrency); + } + + /** @return array */ + public function getSupportedCurrencies(string $driver, string $apiKey, array $driverConfig = []): array + { + return ExchangeRateDriverFactory::make($driver, $apiKey, $driverConfig) + ->getSupportedCurrencies(); + } +} diff --git a/app/Domains/Money/Contracts/ExchangeRateBackfill.php b/app/Domains/Money/Contracts/ExchangeRateBackfill.php new file mode 100644 index 00000000..2584f9dd --- /dev/null +++ b/app/Domains/Money/Contracts/ExchangeRateBackfill.php @@ -0,0 +1,14 @@ + */ + public function currencyIdsMissingRates(): array; + + /** + * @param array $currencies + */ + public function apply(int $companyId, array $currencies): bool; +} diff --git a/app/Support/ExchangeRate/CurrencyConverterDriver.php b/app/Domains/Money/ExchangeRates/CurrencyConverterDriver.php similarity index 98% rename from app/Support/ExchangeRate/CurrencyConverterDriver.php rename to app/Domains/Money/ExchangeRates/CurrencyConverterDriver.php index 3532bad5..638bb0e1 100644 --- a/app/Support/ExchangeRate/CurrencyConverterDriver.php +++ b/app/Domains/Money/ExchangeRates/CurrencyConverterDriver.php @@ -1,6 +1,6 @@ > diff --git a/app/Support/ExchangeRate/ExchangeRateException.php b/app/Domains/Money/ExchangeRates/ExchangeRateException.php similarity index 83% rename from app/Support/ExchangeRate/ExchangeRateException.php rename to app/Domains/Money/ExchangeRates/ExchangeRateException.php index ebd91a41..76bb581e 100644 --- a/app/Support/ExchangeRate/ExchangeRateException.php +++ b/app/Domains/Money/ExchangeRates/ExchangeRateException.php @@ -1,6 +1,6 @@ authorize('create', ExchangeRateProvider::class); - $query = $this->exchangeRateProviderService->checkActiveCurrencies($request); + $payload = $request->getExchangeRateProviderPayload(); + $query = $this->exchangeRateProviderService->checkActiveCurrencies($payload['currencies'] ?? []); if (count($query) !== 0) { return respondJson('currency_used', 'Currency used.'); } - $checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request); - - if ($checkConverterApi->status() == 200) { - $exchangeRateProvider = $this->exchangeRateProviderService->create($request); + try { + $this->exchangeRateProviderService->validateProvider($payload); + $exchangeRateProvider = $this->exchangeRateProviderService->create($payload); return new ExchangeRateProviderResource($exchangeRateProvider); + } catch (ExchangeRateException $exception) { + return respondJson($exception->errorKey, $exception->getMessage()); } - - return $checkConverterApi; } /** @@ -90,21 +89,24 @@ class ExchangeRateProviderController extends Controller { $this->authorize('update', $exchangeRateProvider); - $query = $this->exchangeRateProviderService->checkUpdateActiveCurrencies($exchangeRateProvider, $request); + $payload = $request->getExchangeRateProviderPayload(); + $query = $this->exchangeRateProviderService->checkUpdateActiveCurrencies( + $exchangeRateProvider, + $payload['currencies'] ?? [], + ); if (count($query) !== 0) { return respondJson('currency_used', 'Currency used.'); } - $checkConverterApi = $this->exchangeRateProviderService->checkProviderStatus($request); - - if ($checkConverterApi->status() == 200) { - $this->exchangeRateProviderService->update($exchangeRateProvider, $request); + try { + $this->exchangeRateProviderService->validateProvider($payload); + $this->exchangeRateProviderService->update($exchangeRateProvider, $payload); return new ExchangeRateProviderResource($exchangeRateProvider); + } catch (ExchangeRateException $exception) { + return respondJson($exception->errorKey, $exception->getMessage()); } - - return $checkConverterApi; } /** @@ -162,16 +164,19 @@ class ExchangeRateProviderController extends Controller if ($query) { $filter = Arr::only($query[0], ['key', 'driver', 'driver_config']); - $result = $this->exchangeRateProviderService->getExchangeRate( - $filter['driver'], - $filter['key'], - $filter['driver_config'] ?? [], - $currency->code, - $baseCurrency->code - ); + try { + $exchangeRate = $this->exchangeRateProviderService->getExchangeRate( + $filter['driver'], + $filter['key'], + $filter['driver_config'] ?? [], + $currency->code, + $baseCurrency->code, + ); - if ($result->status() == 200) { - return $result; + return response()->json(['exchangeRate' => $exchangeRate]); + } catch (ExchangeRateException) { + // Fall back to the latest stored rate below, matching the + // existing API behavior when a live provider is unavailable. } } if ($exchangeRate) { @@ -189,11 +194,17 @@ class ExchangeRateProviderController extends Controller { $this->authorize('viewAny', ExchangeRateProvider::class); - return $this->exchangeRateProviderService->getSupportedCurrencies( - $request->driver, - $request->key, - $request->driver_config ?? [] - ); + try { + $currencies = $this->exchangeRateProviderService->getSupportedCurrencies( + $request->driver, + $request->key, + $request->driver_config ?? [], + ); + + return response()->json(['supportedCurrencies' => $currencies]); + } catch (ExchangeRateException $exception) { + return respondJson($exception->errorKey, $exception->getMessage()); + } } public function usedCurrencies(Request $request) @@ -237,87 +248,20 @@ class ExchangeRateProviderController extends Controller public function usedCurrenciesWithoutRate(Request $request) { - $invoices = Invoice::where('exchange_rate', null)->pluck('currency_id')->toArray(); - $taxes = Tax::where('exchange_rate', null)->pluck('currency_id')->toArray(); - $estimates = Estimate::where('exchange_rate', null)->pluck('currency_id')->toArray(); - $payments = Payment::where('exchange_rate', null)->pluck('currency_id')->toArray(); - - $currencies = array_merge($invoices, $taxes, $estimates, $payments); - return response()->json([ - 'currencies' => Currency::whereIn('id', $currencies)->get(), + 'currencies' => Currency::whereIn( + 'id', + $this->exchangeRateBackfill->currencyIdsMissingRates(), + )->get(), ]); } public function bulkUpdate(BulkExchangeRateRequest $request) { - $bulkExchangeRate = CompanySetting::getSetting('bulk_exchange_rate_configured', $request->header('company')); - - if ($bulkExchangeRate == 'NO') { - if ($request->currencies) { - foreach ($request->currencies as $currency) { - $currency['exchange_rate'] = $currency['exchange_rate'] ?? 1; - - $invoices = Invoice::where('currency_id', $currency['id'])->get(); - - if ($invoices) { - foreach ($invoices as $invoice) { - $invoice->update([ - 'exchange_rate' => $currency['exchange_rate'], - 'base_discount_val' => $invoice->sub_total * $currency['exchange_rate'], - 'base_sub_total' => $invoice->sub_total * $currency['exchange_rate'], - 'base_total' => $invoice->total * $currency['exchange_rate'], - 'base_tax' => $invoice->tax * $currency['exchange_rate'], - 'base_due_amount' => $invoice->due_amount * $currency['exchange_rate'], - ]); - - $this->updateItemsExchangeRate($invoice); - } - } - - $estimates = Estimate::where('currency_id', $currency['id'])->get(); - - if ($estimates) { - foreach ($estimates as $estimate) { - $estimate->update([ - 'exchange_rate' => $currency['exchange_rate'], - 'base_discount_val' => $estimate->sub_total * $currency['exchange_rate'], - 'base_sub_total' => $estimate->sub_total * $currency['exchange_rate'], - 'base_total' => $estimate->total * $currency['exchange_rate'], - 'base_tax' => $estimate->tax * $currency['exchange_rate'], - ]); - - $this->updateItemsExchangeRate($estimate); - } - } - - $taxes = Tax::where('currency_id', $currency['id'])->get(); - - if ($taxes) { - foreach ($taxes as $tax) { - $tax->base_amount = $tax->base_amount * $currency['exchange_rate']; - $tax->save(); - } - } - - $payments = Payment::where('currency_id', $currency['id'])->get(); - - if ($payments) { - foreach ($payments as $payment) { - $payment->exchange_rate = $currency['exchange_rate']; - $payment->base_amount = $payment->amount * $currency['exchange_rate']; - $payment->save(); - } - } - } - } - - $settings = [ - 'bulk_exchange_rate_configured' => 'YES', - ]; - - CompanySetting::setSettings($settings, $request->header('company')); - + if ($this->exchangeRateBackfill->apply( + (int) $request->header('company'), + $request->validated('currencies'), + )) { return response()->json([ 'success' => true, ]); @@ -327,33 +271,4 @@ class ExchangeRateProviderController extends Controller 'error' => false, ]); } - - private function updateItemsExchangeRate($model): void - { - foreach ($model->items as $item) { - $item->update([ - 'exchange_rate' => $model->exchange_rate, - 'base_discount_val' => $item->discount_val * $model->exchange_rate, - 'base_price' => $item->price * $model->exchange_rate, - 'base_tax' => $item->tax * $model->exchange_rate, - 'base_total' => $item->total * $model->exchange_rate, - ]); - - $this->updateTaxesExchangeRate($item); - } - - $this->updateTaxesExchangeRate($model); - } - - private function updateTaxesExchangeRate($model): void - { - if ($model->taxes()->exists()) { - $model->taxes->map(function ($tax) use ($model) { - $tax->update([ - 'exchange_rate' => $model->exchange_rate, - 'base_amount' => $tax->amount * $model->exchange_rate, - ]); - }); - } - } } diff --git a/app/Http/Requests/BulkExchangeRateRequest.php b/app/Domains/Money/Http/Requests/BulkExchangeRateRequest.php similarity index 94% rename from app/Http/Requests/BulkExchangeRateRequest.php rename to app/Domains/Money/Http/Requests/BulkExchangeRateRequest.php index 8b04d0cd..dd90ff35 100644 --- a/app/Http/Requests/BulkExchangeRateRequest.php +++ b/app/Domains/Money/Http/Requests/BulkExchangeRateRequest.php @@ -1,6 +1,6 @@ app->bind(ExchangeRateBackfill::class, EloquentExchangeRateBackfill::class); + } + public function boot(): void { + Gate::policy(ExchangeRateProvider::class, ExchangeRateProviderPolicy::class); + $this->registerExchangeRateDrivers(); - $this->registerAiDrivers(); } protected function registerExchangeRateDrivers(): void @@ -64,34 +74,4 @@ class DriverRegistryProvider extends ServiceProvider 'website' => 'https://openexchangerates.org', ]); } - - protected function registerAiDrivers(): void - { - Registry::registerAiDriver('openrouter', [ - 'class' => OpenRouterDriver::class, - 'label' => 'settings.ai.openrouter', - 'website' => 'https://openrouter.ai', - 'default_base_url' => 'https://openrouter.ai/api/v1', - 'supported_roles' => ['chat', 'text_generation'], - 'suggested_models' => [ - ['value' => 'anthropic/claude-sonnet-4.6', 'label' => 'Anthropic Claude Sonnet 4.6'], - ['value' => 'anthropic/claude-haiku-4.5', 'label' => 'Anthropic Claude Haiku 4.5'], - ['value' => 'anthropic/claude-opus-4.6', 'label' => 'Anthropic Claude Opus 4.6'], - ['value' => 'openai/gpt-5.4', 'label' => 'OpenAI GPT-5.4'], - ['value' => 'openai/gpt-5.4-mini', 'label' => 'OpenAI GPT-5.4 mini'], - ['value' => 'google/gemini-3.1-pro-preview', 'label' => 'Google Gemini 3.1 Pro (preview)'], - ['value' => 'google/gemini-3.1-flash-lite-preview', 'label' => 'Google Gemini 3.1 Flash Lite (preview)'], - ['value' => 'z-ai/glm-5.1', 'label' => 'Z.AI GLM 5.1'], - ['value' => 'z-ai/glm-4.7-flash', 'label' => 'Z.AI GLM 4.7 Flash'], - ], - 'config_fields' => [ - [ - 'key' => 'base_url', - 'type' => 'text', - 'label' => 'settings.ai.base_url', - 'default' => 'https://openrouter.ai/api/v1', - ], - ], - ]); - } } diff --git a/app/Policies/ExchangeRateProviderPolicy.php b/app/Domains/Money/Policies/ExchangeRateProviderPolicy.php similarity index 95% rename from app/Policies/ExchangeRateProviderPolicy.php rename to app/Domains/Money/Policies/ExchangeRateProviderPolicy.php index ff64bdb0..1f7cfaf9 100644 --- a/app/Policies/ExchangeRateProviderPolicy.php +++ b/app/Domains/Money/Policies/ExchangeRateProviderPolicy.php @@ -1,9 +1,9 @@ expenseTaxManager->clear($expense); + } +} diff --git a/app/Domains/Purchases/Application/ExpenseService.php b/app/Domains/Purchases/Application/ExpenseService.php new file mode 100644 index 00000000..ca905a7b --- /dev/null +++ b/app/Domains/Purchases/Application/ExpenseService.php @@ -0,0 +1,100 @@ + $attributes + * @param array|null $taxes + */ + public function create( + array $attributes, + ?array $taxes = null, + ?PendingExpenseReceipt $receipt = null, + ?iterable $customFields = null, + ): Expense { + $expense = DB::transaction(function () use ($attributes, $taxes): Expense { + $expense = Expense::create($attributes); + + if ($taxes !== null) { + $this->expenseTaxManager->replace($expense, $taxes); + } + + return $expense; + }); + + $companyCurrency = CompanySetting::getSetting('currency', $expense->company_id); + + if ((string) $expense['currency_id'] !== $companyCurrency) { + $this->expenseExchangeRateRecorder->record($expense); + } + + if ($receipt) { + $this->expenseReceiptManager->attach($expense, $receipt); + } + + if ($customFields) { + $this->customFieldValueWriter->attach($expense, $customFields); + } + + return $expense->load('taxes.taxType'); + } + + /** + * @param array $attributes + * @param array|null $taxes + */ + public function update( + Expense $expense, + array $attributes, + ?array $taxes = null, + ?PendingExpenseReceipt $receipt = null, + bool $removeReceipt = false, + ?iterable $customFields = null, + ): Expense { + DB::transaction(function () use ($expense, $attributes, $taxes): void { + $expense->update($attributes); + + if ($taxes !== null) { + $this->expenseTaxManager->replace($expense, $taxes); + } + }); + + $companyCurrency = CompanySetting::getSetting('currency', $expense->company_id); + + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->expenseExchangeRateRecorder->record($expense); + } + + if ($removeReceipt) { + $this->expenseReceiptManager->clear($expense); + } + + if ($receipt) { + $this->expenseReceiptManager->replace($expense, $receipt); + } + + if ($customFields) { + $this->customFieldValueWriter->update($expense, $customFields); + } + + return $expense->fresh('taxes.taxType'); + } +} diff --git a/app/Domains/Purchases/Contracts/ExpenseExchangeRateRecorder.php b/app/Domains/Purchases/Contracts/ExpenseExchangeRateRecorder.php new file mode 100644 index 00000000..d3427994 --- /dev/null +++ b/app/Domains/Purchases/Contracts/ExpenseExchangeRateRecorder.php @@ -0,0 +1,10 @@ + $taxes */ + public function replace(Expense $expense, array $taxes): void; + + public function clear(Expense $expense): void; +} diff --git a/app/Domains/Purchases/Data/PendingExpenseReceipt.php b/app/Domains/Purchases/Data/PendingExpenseReceipt.php new file mode 100644 index 00000000..97229b36 --- /dev/null +++ b/app/Domains/Purchases/Data/PendingExpenseReceipt.php @@ -0,0 +1,11 @@ +authorize('create', Expense::class); - $expense = $this->expenseService->create($request); + $expense = $this->expenseService->create( + attributes: $request->getExpensePayload(), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + receipt: $this->receipt($request), + customFields: $this->customFields($request), + ); return new ExpenseResource($expense); } @@ -80,7 +88,14 @@ class ExpensesController extends Controller { $this->authorize('update', $expense); - $expense = $this->expenseService->update($expense, $request); + $expense = $this->expenseService->update( + expense: $expense, + attributes: $request->getExpensePayload(), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + receipt: $this->receipt($request), + removeReceipt: (bool) $request->input('is_attachment_receipt_removed', false), + customFields: $this->customFields($request), + ); return new ExpenseResource($expense); } @@ -104,15 +119,13 @@ class ExpensesController extends Controller { $this->authorize('view', $expense); - if ($expense) { - $media = $expense->getFirstMedia('receipts'); + $receipt = $this->expenseReceiptManager->first($expense); - if ($media) { - return response()->file($media->getPath()); - } - - return respondJson('receipt_does_not_exist', 'Receipt does not exist.'); + if ($receipt) { + return response()->file($receipt->path); } + + return respondJson('receipt_does_not_exist', 'Receipt does not exist.'); } public function uploadReceipt(UploadExpenseReceiptRequest $request, Expense $expense) @@ -122,13 +135,12 @@ class ExpensesController extends Controller $data = json_decode($request->attachment_receipt); if ($data) { - if ($request->type === 'edit') { - $expense->clearMediaCollection('receipts'); - } - - $expense->addMediaFromBase64($data->data) - ->usingFileName($data->name) - ->toMediaCollection('receipts'); + $this->expenseReceiptManager->attachBase64( + $expense, + $data->data, + $data->name, + $request->type === 'edit', + ); } return response()->json([ @@ -140,21 +152,49 @@ class ExpensesController extends Controller { $this->authorize('view', $expense); - if ($expense) { - $media = $expense->getFirstMedia('receipts'); - if ($media) { - $imagePath = $media->getPath(); - $response = \Response::download($imagePath, $media->file_name); - if (ob_get_contents()) { - ob_end_clean(); - } + $receipt = $this->expenseReceiptManager->first($expense); - return $response; + if ($receipt) { + $response = response()->download($receipt->path, $receipt->fileName); + if (ob_get_contents()) { + ob_end_clean(); } + + return $response; } return response()->json([ 'error' => 'receipt_not_found', ]); } + + /** @return array|null */ + private function customFields(ExpenseRequest $request): ?array + { + $customFields = $request->input('customFields'); + + if (! $customFields) { + return null; + } + + if (is_string($customFields)) { + $customFields = json_decode($customFields); + } + + return is_array($customFields) ? $customFields : null; + } + + private function receipt(ExpenseRequest $request): ?PendingExpenseReceipt + { + $receipt = $request->file('attachment_receipt'); + + if (! $receipt) { + return null; + } + + return new PendingExpenseReceipt( + $receipt->getPathname(), + $receipt->getClientOriginalName(), + ); + } } diff --git a/app/Http/Controllers/CustomerPortal/Expense/ExpensesController.php b/app/Domains/Purchases/Http/Controllers/CustomerPortal/ExpensesController.php similarity index 84% rename from app/Http/Controllers/CustomerPortal/Expense/ExpensesController.php rename to app/Domains/Purchases/Http/Controllers/CustomerPortal/ExpensesController.php index 6863c601..cd2968d9 100644 --- a/app/Http/Controllers/CustomerPortal/Expense/ExpensesController.php +++ b/app/Domains/Purchases/Http/Controllers/CustomerPortal/ExpensesController.php @@ -1,11 +1,11 @@ taxes()->delete(); - }); - } - public function registerMediaCollections(): void { $this->addMediaCollection('receipts'); diff --git a/app/Models/ExpenseCategory.php b/app/Domains/Purchases/Models/ExpenseCategory.php similarity index 93% rename from app/Models/ExpenseCategory.php rename to app/Domains/Purchases/Models/ExpenseCategory.php index bf86c2fa..30f66ce4 100644 --- a/app/Models/ExpenseCategory.php +++ b/app/Domains/Purchases/Models/ExpenseCategory.php @@ -1,7 +1,9 @@ app->bind(ExpenseTaxManager::class, TaxationExpenseTaxManager::class); + $this->app->bind(ExpenseExchangeRateRecorder::class, MoneyExpenseExchangeRateRecorder::class); + $this->app->bind(ExpenseReceiptManager::class, MediaLibraryExpenseReceiptManager::class); + } + + public function boot(): void + { + Expense::observe(ClearExpenseTaxes::class); + + Gate::policy(Expense::class, ExpensePolicy::class); + Gate::policy(ExpenseCategory::class, ExpenseCategoryPolicy::class); + Gate::define('delete multiple expenses', [ExpensePolicy::class, 'deleteMultiple']); + } +} diff --git a/app/Domains/Purchases/routes/company.php b/app/Domains/Purchases/routes/company.php new file mode 100644 index 00000000..63b2542c --- /dev/null +++ b/app/Domains/Purchases/routes/company.php @@ -0,0 +1,11 @@ +invoiceBalanceService->recalculate($invoice); + $this->invoiceBalanceUpdater->recalculate($invoice); } } @@ -226,7 +227,7 @@ class PaymentAllocationService ->where('invoice_id', $invoice->id) ->where('payment_id', '!=', $payment->id) ->sum('amount'); - $available = max(0, (int) $invoice->total - $this->invoiceBalanceService->creditedTotal($invoice) - $allocatedByOtherPayments); + $available = max(0, (int) $invoice->total - $this->invoiceBalanceUpdater->creditedTotal($invoice) - $allocatedByOtherPayments); if ($amount > $available) { throw ValidationException::withMessages([ diff --git a/app/Domains/Receivables/Application/PaymentService.php b/app/Domains/Receivables/Application/PaymentService.php new file mode 100644 index 00000000..f2083408 --- /dev/null +++ b/app/Domains/Receivables/Application/PaymentService.php @@ -0,0 +1,214 @@ + $attributes + * @param array $allocations + */ + public function create( + array $attributes, + array $allocations = [], + ?iterable $customFields = null, + ): Payment { + $payment = DB::transaction(function () use ($attributes, $allocations, $customFields): Payment { + $payment = Payment::create($attributes); + $payment->unique_hash = Hashids::connection(HashidConnection::Payment->value)->encode($payment->id); + + $numbering = $this->paymentNumberAssigner->next( + $payment, + (int) $payment->company_id, + (int) $payment->customer_id, + ); + + $payment->sequence_number = $numbering->sequence; + $payment->customer_sequence_number = $numbering->customerSequence; + $payment->save(); + + $this->paymentAllocationService->replace($payment, $allocations); + + $companyCurrency = CompanySetting::getSetting('currency', $payment->company_id); + + if ((string) $payment->currency_id !== $companyCurrency) { + $this->paymentExchangeRateRecorder->record($payment); + } + + if ($customFields) { + $this->customFieldValueWriter->attach($payment, $customFields); + } + + return $payment; + }); + + return $this->loadPayment($payment); + } + + /** + * @param array $attributes + * @param array $allocations + */ + public function update( + Payment $payment, + array $attributes, + bool $replaceAllocations, + array $allocations = [], + ?iterable $customFields = null, + ): Payment { + $payment = DB::transaction(function () use ($payment, $attributes, $replaceAllocations, $allocations, $customFields): Payment { + $lockedPayment = Payment::query()->whereKey($payment->id)->lockForUpdate()->firstOrFail(); + $targetAllocations = $replaceAllocations + ? $allocations + : $lockedPayment->allocations() + ->get(['invoice_id', 'amount']) + ->map(fn ($allocation) => [ + 'invoice_id' => (int) $allocation->invoice_id, + 'amount' => (int) $allocation->amount, + ]) + ->all(); + $customerChanged = (int) $lockedPayment->customer_id !== (int) $attributes['customer_id']; + + if ($customerChanged && $targetAllocations !== []) { + throw ValidationException::withMessages([ + 'customer_id' => ['payment_customer_change_requires_unallocated_credit'], + ]); + } + + $numbering = $this->paymentNumberAssigner->next( + $lockedPayment, + (int) $lockedPayment->company_id, + (int) $attributes['customer_id'], + ); + + $attributes['customer_sequence_number'] = $numbering->customerSequence; + $lockedPayment->update($attributes); + $this->paymentAllocationService->replace($lockedPayment, $targetAllocations); + + $companyCurrency = CompanySetting::getSetting('currency', $lockedPayment->company_id); + + if ((string) $lockedPayment->currency_id !== $companyCurrency) { + $this->paymentExchangeRateRecorder->record($lockedPayment); + } + + if ($customFields) { + $this->customFieldValueWriter->update($lockedPayment, $customFields); + } + + return $lockedPayment; + }); + + return $this->loadPayment($payment); + } + + public function delete(Collection $ids): bool + { + DB::transaction(function () use ($ids): void { + foreach ($ids->sort() as $id) { + $payment = Payment::query()->whereKey($id)->lockForUpdate()->first(); + + if (! $payment) { + continue; + } + + $this->paymentAllocationService->replace($payment, []); + $payment->delete(); + } + }); + + return true; + } + + public function sendPaymentData(Payment $payment, array $data): array + { + $data['payment'] = $payment->toArray(); + $data['user'] = $payment->customer->toArray(); + $data['company'] = Company::find($payment->company_id); + $data['body'] = $payment->getEmailBody($data['body']); + $data['attach']['data'] = ($payment->getEmailAttachmentSetting()) ? $this->getPdfData($payment) : null; + + return $data; + } + + public function send(Payment $payment, array $data): array + { + $data = $this->sendPaymentData($payment, $data); + + $this->mailConfigurator->applyCompanyConfig($payment->company_id); + + $this->paymentEmailSender->send($data); + + return [ + 'success' => true, + ]; + } + + public function getPdfData(Payment $payment): mixed + { + $payment->loadMissing('allocations.invoice.currency'); + + $company = Company::find($payment->company_id); + $locale = CompanySetting::getSetting('language', $company->id); + + \App::setLocale($locale); + + $logo = $company->logo_path; + + view()->share([ + 'payment' => $payment, + 'company_address' => $payment->getCompanyAddress(), + 'billing_address' => $payment->getCustomerBillingAddress(), + 'notes' => $payment->getNotes(), + 'logo' => $logo ?? null, + ]); + + $templatePath = PdfTemplateUtils::resolveView('payment', 'payment'); + + if (request()->has('preview')) { + return view($templatePath); + } + + return Pdf::loadView($templatePath, PdfMetadata::forDocument( + __('pdf_payment_label'), + $payment->payment_number, + $company, + )); + } + + private function loadPayment(Payment $payment): Payment + { + return Payment::with([ + 'customer', + 'allocations.invoice', + 'paymentMethod', + 'fields', + ])->findOrFail($payment->id); + } +} diff --git a/app/Services/Document/TransactionService.php b/app/Domains/Receivables/Application/TransactionService.php similarity index 67% rename from app/Services/Document/TransactionService.php rename to app/Domains/Receivables/Application/TransactionService.php index 1af10d11..000e473b 100644 --- a/app/Services/Document/TransactionService.php +++ b/app/Domains/Receivables/Application/TransactionService.php @@ -1,16 +1,17 @@ unique_hash = Hashids::connection(Transaction::class)->encode($transaction->id); + $transaction->unique_hash = Hashids::connection(HashidConnection::Transaction->value)->encode($transaction->id); $transaction->save(); return $transaction; diff --git a/app/Domains/Receivables/Contracts/InvoiceBalanceUpdater.php b/app/Domains/Receivables/Contracts/InvoiceBalanceUpdater.php new file mode 100644 index 00000000..34bd16e5 --- /dev/null +++ b/app/Domains/Receivables/Contracts/InvoiceBalanceUpdater.php @@ -0,0 +1,12 @@ + $data */ + public function send(array $data): void; +} diff --git a/app/Domains/Receivables/Contracts/PaymentExchangeRateRecorder.php b/app/Domains/Receivables/Contracts/PaymentExchangeRateRecorder.php new file mode 100644 index 00000000..b4053c70 --- /dev/null +++ b/app/Domains/Receivables/Contracts/PaymentExchangeRateRecorder.php @@ -0,0 +1,10 @@ +authorize('create', PaymentMethod::class); - $paymentMethod = PaymentMethod::createPaymentMethod($request); + $paymentMethod = PaymentMethod::create($request->getPaymentMethodPayload()); return new PaymentMethodResource($paymentMethod); } diff --git a/app/Http/Controllers/Company/Payment/PaymentsController.php b/app/Domains/Receivables/Http/Controllers/Company/PaymentsController.php similarity index 71% rename from app/Http/Controllers/Company/Payment/PaymentsController.php rename to app/Domains/Receivables/Http/Controllers/Company/PaymentsController.php index 2a1eb325..bec9d444 100644 --- a/app/Http/Controllers/Company/Payment/PaymentsController.php +++ b/app/Domains/Receivables/Http/Controllers/Company/PaymentsController.php @@ -1,16 +1,16 @@ authorize('create', Payment::class); - $payment = $this->paymentService->create($request); + $payment = $this->paymentService->create( + attributes: $request->getPaymentPayload(), + allocations: $request->validated('allocations') ?? [], + customFields: $this->customFields($request), + ); return new PaymentResource($payment); } @@ -74,7 +78,13 @@ class PaymentsController extends Controller { $this->authorize('update', $payment); - $payment = $this->paymentService->update($payment, $request); + $payment = $this->paymentService->update( + payment: $payment, + attributes: $request->getPaymentPayload(), + replaceAllocations: $request->exists('allocations'), + allocations: $request->validated('allocations') ?? [], + customFields: $this->customFields($request), + ); return new PaymentResource($payment); } @@ -125,4 +135,11 @@ class PaymentsController extends Controller return $markdown->render('emails.send.payment', ['data' => $data]); } + + private function customFields(PaymentRequest $request): ?iterable + { + $customFields = $request->input('customFields'); + + return is_iterable($customFields) ? $customFields : null; + } } diff --git a/app/Http/Controllers/CustomerPortal/Payment/PaymentMethodController.php b/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentMethodController.php similarity index 58% rename from app/Http/Controllers/CustomerPortal/Payment/PaymentMethodController.php rename to app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentMethodController.php index e1bc8675..b5348720 100644 --- a/app/Http/Controllers/CustomerPortal/Payment/PaymentMethodController.php +++ b/app/Domains/Receivables/Http/Controllers/CustomerPortal/PaymentMethodController.php @@ -1,11 +1,11 @@ has('preview')) { + return $this->paymentPdfDataProvider->getPdfData($payment); + } + + return $payment->getGeneratedPDFOrStream('payment'); + } +} diff --git a/app/Http/Controllers/CustomerPortal/PaymentPdfController.php b/app/Domains/Receivables/Http/Controllers/PublicPaymentController.php similarity index 68% rename from app/Http/Controllers/CustomerPortal/PaymentPdfController.php rename to app/Domains/Receivables/Http/Controllers/PublicPaymentController.php index 44ab7d6e..83a7f59a 100644 --- a/app/Http/Controllers/CustomerPortal/PaymentPdfController.php +++ b/app/Domains/Receivables/Http/Controllers/PublicPaymentController.php @@ -1,14 +1,14 @@ data['payment']['id']); + $token = app(EmailLogWriter::class)->record($payment, [ 'from' => $this->data['from'], 'to' => $this->data['to'], 'cc' => $this->data['cc'] ?? null, 'bcc' => $this->data['bcc'] ?? null, 'subject' => $this->data['subject'], 'body' => $this->data['body'], - 'mailable_type' => Payment::class, - 'mailable_id' => $this->data['payment']['id'], ]); - $log->token = Hashids::connection(EmailLog::class)->encode($log->id); - $log->save(); - - $this->data['url'] = route('payment', ['email_log' => $log->token]); + $this->data['url'] = route('payment', ['email_log' => $token]); $mailContent = $this->from($this->data['from'], config('mail.from.name')) ->subject($this->data['subject']) diff --git a/app/Models/Payment.php b/app/Domains/Receivables/Models/Payment.php similarity index 90% rename from app/Models/Payment.php rename to app/Domains/Receivables/Models/Payment.php index 5e367718..fe0e2ca4 100644 --- a/app/Models/Payment.php +++ b/app/Domains/Receivables/Models/Payment.php @@ -1,13 +1,20 @@ morphMany('App\Models\EmailLog', 'mailable'); + return $this->morphMany(EmailLog::class, 'mailable'); } public function customer(): BelongsTo @@ -225,7 +234,7 @@ class Payment extends Model implements HasMedia public function getPDFData(): mixed { - return app(PaymentService::class)->getPdfData($this); + return app(PaymentPdfDataProvider::class)->getPdfData($this); } public function getCompanyAddress(): string|false diff --git a/app/Models/PaymentAllocation.php b/app/Domains/Receivables/Models/PaymentAllocation.php similarity index 82% rename from app/Models/PaymentAllocation.php rename to app/Domains/Receivables/Models/PaymentAllocation.php index b4d7d7f6..8419b3e7 100644 --- a/app/Models/PaymentAllocation.php +++ b/app/Domains/Receivables/Models/PaymentAllocation.php @@ -1,13 +1,16 @@ paginate($limit); } - /** - * Create a new payment method from a validated form request. - */ - public static function createPaymentMethod(mixed $request): self - { - $data = $request->getPaymentMethodPayload(); - - $paymentMethod = self::create($data); - - return $paymentMethod; - } - /** * Retrieve the settings array for a payment method by its ID. */ diff --git a/app/Models/Transaction.php b/app/Domains/Receivables/Models/Transaction.php similarity index 87% rename from app/Models/Transaction.php rename to app/Domains/Receivables/Models/Transaction.php index 0ddccdcf..b9c8a0fe 100644 --- a/app/Models/Transaction.php +++ b/app/Domains/Receivables/Models/Transaction.php @@ -1,7 +1,10 @@ app->bind(PaymentPdfDataProvider::class, PaymentService::class); + $this->app->bind(InvoiceBalanceUpdater::class, SalesInvoiceBalanceUpdater::class); + $this->app->bind(PaymentNumberAssigner::class, SalesPaymentNumberAssigner::class); + $this->app->bind(PaymentExchangeRateRecorder::class, MoneyPaymentExchangeRateRecorder::class); + $this->app->bind(PaymentEmailSender::class, LaravelPaymentEmailSender::class); + } + + public function boot(): void + { + Gate::policy(Payment::class, PaymentPolicy::class); + Gate::policy(PaymentMethod::class, PaymentMethodPolicy::class); + Gate::define('send payment', [PaymentPolicy::class, 'send']); + Gate::define('delete multiple payments', [PaymentPolicy::class, 'deleteMultiple']); + } +} diff --git a/app/Domains/Receivables/routes/company.php b/app/Domains/Receivables/routes/company.php new file mode 100644 index 00000000..363879b5 --- /dev/null +++ b/app/Domains/Receivables/routes/company.php @@ -0,0 +1,14 @@ +name('payment'); diff --git a/app/Http/Controllers/Company/Customer/CustomerStatementController.php b/app/Domains/Reporting/Http/Controllers/Company/CustomerStatementController.php similarity index 58% rename from app/Http/Controllers/Company/Customer/CustomerStatementController.php rename to app/Domains/Reporting/Http/Controllers/Company/CustomerStatementController.php index 6dd011d2..c3d8760f 100644 --- a/app/Http/Controllers/Company/Customer/CustomerStatementController.php +++ b/app/Domains/Reporting/Http/Controllers/Company/CustomerStatementController.php @@ -1,18 +1,18 @@ validated('type'); - $statement = $this->customerStatementService->statement( + $statement = $this->customerStatementQuery->statement( $customer, $type, Carbon::createFromFormat('Y-m-d', $request->validated('from_date')), - Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementService::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), + Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementQuery::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), (int) $request->validated('per_page', 50), (int) $request->validated('page', 1), ); diff --git a/app/Http/Controllers/Company/Dashboard/DashboardController.php b/app/Domains/Reporting/Http/Controllers/Company/DashboardController.php similarity index 93% rename from app/Http/Controllers/Company/Dashboard/DashboardController.php rename to app/Domains/Reporting/Http/Controllers/Company/DashboardController.php index 8e7ad2f5..0eea67dd 100644 --- a/app/Http/Controllers/Company/Dashboard/DashboardController.php +++ b/app/Domains/Reporting/Http/Controllers/Company/DashboardController.php @@ -1,15 +1,15 @@ validated('type'); - $statement = $this->customerStatementService->statement( + $statement = $this->customerStatementQuery->statement( $customer, $type, Carbon::createFromFormat('Y-m-d', $request->validated('from_date')), - Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementService::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), + Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementQuery::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), PHP_INT_MAX, ); - $pdf = $this->customerStatementPdfService->render($statement); + $pdf = $this->customerStatementPdfRenderer->render($statement); - CompanyMailConfigService::apply($customer->company_id); + $this->mailConfigurator->applyCompanyConfig($customer->company_id); $mail = Mail::to($request->validated('to')); if ($request->filled('cc')) { diff --git a/app/Http/Controllers/Company/Report/CustomerSalesReportController.php b/app/Domains/Reporting/Http/Controllers/CustomerSalesReportController.php similarity index 89% rename from app/Http/Controllers/Company/Report/CustomerSalesReportController.php rename to app/Domains/Reporting/Http/Controllers/CustomerSalesReportController.php index b67e8222..66d672d8 100644 --- a/app/Http/Controllers/Company/Report/CustomerSalesReportController.php +++ b/app/Domains/Reporting/Http/Controllers/CustomerSalesReportController.php @@ -1,15 +1,15 @@ validated('type'); - $statement = $this->customerStatementService->statement( + $statement = $this->customerStatementQuery->statement( $customer, $type, Carbon::createFromFormat('Y-m-d', $request->validated('from_date')), - Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementService::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), + Carbon::createFromFormat('Y-m-d', $request->validated($type === CustomerStatementQuery::TYPE_OUTSTANDING ? 'as_of' : 'to_date')), PHP_INT_MAX, ); - $pdf = $this->customerStatementPdfService->render($statement); + $pdf = $this->customerStatementPdfRenderer->render($statement); if ($request->boolean('preview')) { return view('app.pdf.reports.customer-statement', [ diff --git a/app/Http/Controllers/Company/Report/ExpensesReportController.php b/app/Domains/Reporting/Http/Controllers/ExpensesReportController.php similarity index 89% rename from app/Http/Controllers/Company/Report/ExpensesReportController.php rename to app/Domains/Reporting/Http/Controllers/ExpensesReportController.php index 6b07367a..242c7551 100644 --- a/app/Http/Controllers/Company/Report/ExpensesReportController.php +++ b/app/Domains/Reporting/Http/Controllers/ExpensesReportController.php @@ -1,15 +1,15 @@ ['required', Rule::in([CustomerStatementService::TYPE_ACTIVITY, CustomerStatementService::TYPE_OUTSTANDING])], + 'type' => ['required', Rule::in([CustomerStatementQuery::TYPE_ACTIVITY, CustomerStatementQuery::TYPE_OUTSTANDING])], 'from_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity'], 'to_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity', 'after_or_equal:from_date'], 'as_of' => ['nullable', 'date_format:Y-m-d', 'required_if:type,outstanding'], @@ -36,7 +36,7 @@ class CustomerStatementRequest extends FormRequest protected function prepareForValidation(): void { - $type = $this->input('type', CustomerStatementService::TYPE_ACTIVITY); + $type = $this->input('type', CustomerStatementQuery::TYPE_ACTIVITY); $today = Carbon::today(); $this->merge([ diff --git a/app/Http/Requests/SendCustomerStatementRequest.php b/app/Domains/Reporting/Http/Requests/SendCustomerStatementRequest.php similarity index 82% rename from app/Http/Requests/SendCustomerStatementRequest.php rename to app/Domains/Reporting/Http/Requests/SendCustomerStatementRequest.php index 42fc82fa..cdcc4ee4 100644 --- a/app/Http/Requests/SendCustomerStatementRequest.php +++ b/app/Domains/Reporting/Http/Requests/SendCustomerStatementRequest.php @@ -1,8 +1,8 @@ ['required', Rule::in([CustomerStatementService::TYPE_ACTIVITY, CustomerStatementService::TYPE_OUTSTANDING])], + 'type' => ['required', Rule::in([CustomerStatementQuery::TYPE_ACTIVITY, CustomerStatementQuery::TYPE_OUTSTANDING])], 'from_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity'], 'to_date' => ['nullable', 'date_format:Y-m-d', 'required_if:type,activity', 'after_or_equal:from_date'], 'as_of' => ['nullable', 'date_format:Y-m-d', 'required_if:type,outstanding'], @@ -39,7 +39,7 @@ class SendCustomerStatementRequest extends FormRequest protected function prepareForValidation(): void { - $type = $this->input('type', CustomerStatementService::TYPE_ACTIVITY); + $type = $this->input('type', CustomerStatementQuery::TYPE_ACTIVITY); $today = Carbon::today(); $this->merge([ diff --git a/app/Http/Resources/CustomerStatementResource.php b/app/Domains/Reporting/Http/Resources/CustomerStatementResource.php similarity index 96% rename from app/Http/Resources/CustomerStatementResource.php rename to app/Domains/Reporting/Http/Resources/CustomerStatementResource.php index f4dc6850..b69a8ecd 100644 --- a/app/Http/Resources/CustomerStatementResource.php +++ b/app/Domains/Reporting/Http/Resources/CustomerStatementResource.php @@ -1,7 +1,9 @@ $this->data['bcc'] ?? null, 'subject' => $this->data['subject'], 'body' => $this->data['body'], - 'mailable_type' => $this->data['customer']::class, + 'mailable_type' => $this->data['customer']->getMorphClass(), 'mailable_id' => $this->data['customer']->id, ]); diff --git a/app/Policies/DashboardPolicy.php b/app/Domains/Reporting/Policies/DashboardPolicy.php similarity index 74% rename from app/Policies/DashboardPolicy.php rename to app/Domains/Reporting/Policies/DashboardPolicy.php index 94f16b71..b5408f91 100644 --- a/app/Policies/DashboardPolicy.php +++ b/app/Domains/Reporting/Policies/DashboardPolicy.php @@ -1,9 +1,9 @@ $invoice->sales_tax_address_type, ]); - $creditNote->unique_hash = Hashids::connection(Invoice::class)->encode($creditNote->id); + $creditNote->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($creditNote->id); $creditNote->save(); // recompute: false throughout. The calculator has already decided every @@ -261,7 +264,7 @@ class CreditNoteService ]; } - $creditNote->addCustomFields($customFields); + $this->customFieldValueWriter->attach($creditNote, $customFields); } return $creditNote; diff --git a/app/Services/Document/DocumentItemService.php b/app/Domains/Sales/Application/DocumentItemService.php similarity index 93% rename from app/Services/Document/DocumentItemService.php rename to app/Domains/Sales/Application/DocumentItemService.php index 1c9bdd32..d839821e 100644 --- a/app/Services/Document/DocumentItemService.php +++ b/app/Domains/Sales/Application/DocumentItemService.php @@ -1,12 +1,17 @@ addCustomFields($item['custom_fields']); + $this->customFieldValueWriter->attach($createdItem, $item['custom_fields']); } } } diff --git a/app/Services/Document/EstimateService.php b/app/Domains/Sales/Application/EstimateService.php similarity index 77% rename from app/Services/Document/EstimateService.php rename to app/Domains/Sales/Application/EstimateService.php index 057a2b10..db12be48 100644 --- a/app/Services/Document/EstimateService.php +++ b/app/Domains/Sales/Application/EstimateService.php @@ -1,40 +1,49 @@ getEstimatePayload(); - - if ($request->has('estimateSend')) { - $data['status'] = Estimate::STATUS_SENT; - } - - $estimate = Estimate::create($data); - $estimate->unique_hash = Hashids::connection(Estimate::class)->encode($estimate->id); + /** + * @param array $attributes + * @param array> $items + * @param array>|null $taxes + */ + public function create( + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Estimate { + $estimate = Estimate::create($attributes); + $estimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($estimate->id); $serial = (new SerialNumberService) ->setModel($estimate) ->setCompany($estimate->company_id) @@ -45,46 +54,47 @@ class EstimateService $estimate->customer_sequence_number = $serial->nextCustomerSequenceNumber; $estimate->save(); - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + $companyCurrency = CompanySetting::getSetting('currency', $estimate->company_id); - if ((string) $data['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($estimate); + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($estimate); } - $this->documentItemService->createItems($estimate, $request->items); + $this->documentItemService->createItems($estimate, $items); - if ($request->has('taxes') && (! empty($request->taxes))) { - $this->documentItemService->createTaxes($estimate, $request->taxes); + if ($taxes) { + $this->documentItemService->createTaxes($estimate, $taxes); } - $customFields = $request->customFields; - if ($customFields) { - $estimate->addCustomFields($customFields); + $this->customFieldValueWriter->attach($estimate, $customFields); } return $estimate; } - public function update(Estimate $estimate, Request $request): Estimate - { - $data = $request->getEstimatePayload(); - + public function update( + Estimate $estimate, + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Estimate { $serial = (new SerialNumberService) ->setModel($estimate) ->setCompany($estimate->company_id) - ->setCustomer($request->customer_id) + ->setCustomer($attributes['customer_id']) ->setModelObject($estimate->id) ->setNextNumbers(); - $data['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; + $attributes['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; - $estimate->update($data); + $estimate->update($attributes); - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + $companyCurrency = CompanySetting::getSetting('currency', $estimate->company_id); - if ((string) $data['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($estimate); + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($estimate); } $estimate->items->map(function ($item) { @@ -98,14 +108,14 @@ class EstimateService $estimate->items()->delete(); $estimate->taxes()->delete(); - $this->documentItemService->createItems($estimate, $request->items); + $this->documentItemService->createItems($estimate, $items); - if ($request->has('taxes') && (! empty($request->taxes))) { - $this->documentItemService->createTaxes($estimate, $request->taxes); + if ($taxes) { + $this->documentItemService->createTaxes($estimate, $taxes); } - if ($request->customFields) { - $estimate->updateCustomFields($request->customFields); + if ($customFields) { + $this->customFieldValueWriter->update($estimate, $customFields); } return Estimate::with([ @@ -114,7 +124,7 @@ class EstimateService 'items.fields.customField', 'customer', 'taxes', - ])->find($estimate->id); + ])->findOrFail($estimate->id); } public function sendEstimateData(Estimate $estimate, array $data): array @@ -132,21 +142,14 @@ class EstimateService { $data = $this->sendEstimateData($estimate, $data); - CompanyMailConfigService::apply($estimate->company_id); + $this->mailConfigurator->applyCompanyConfig($estimate->company_id); if ($estimate->status == Estimate::STATUS_DRAFT) { $estimate->status = Estimate::STATUS_SENT; $estimate->save(); } - $mail = \Mail::to($data['to']); - if (! empty($data['cc'])) { - $mail->cc($data['cc']); - } - if (! empty($data['bcc'])) { - $mail->bcc($data['bcc']); - } - $mail->send(new SendEstimateMail($data)); + $this->estimateEmailSender->send($data); return [ 'success' => true, @@ -154,7 +157,7 @@ class EstimateService ]; } - public function getPdfData(Estimate $estimate) + public function getPdfData(Estimate $estimate): mixed { $taxes = collect(); @@ -266,7 +269,7 @@ class EstimateService 'sales_tax_address_type' => $estimate->sales_tax_address_type, ]); - $newEstimate->unique_hash = Hashids::connection(Estimate::class)->encode($newEstimate->id); + $newEstimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($newEstimate->id); $newEstimate->save(); $estimate->load('items.taxes'); @@ -286,7 +289,7 @@ class EstimateService ]; } - $newEstimate->addCustomFields($customFields); + $this->customFieldValueWriter->attach($newEstimate, $customFields); } return $newEstimate; @@ -355,7 +358,7 @@ class EstimateService 'sales_tax_address_type' => $estimate->sales_tax_address_type, ]); - $invoice->unique_hash = Hashids::connection(Invoice::class)->encode($invoice->id); + $invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id); $invoice->save(); $this->documentItemService->createItems($invoice, $estimate->items->toArray()); @@ -374,7 +377,7 @@ class EstimateService ]; } - $invoice->addCustomFields($customFields); + $this->customFieldValueWriter->attach($invoice, $customFields); } $estimate->checkForEstimateConvertAction(); diff --git a/app/Services/Document/InvoiceBalanceService.php b/app/Domains/Sales/Application/InvoiceBalanceService.php similarity index 94% rename from app/Services/Document/InvoiceBalanceService.php rename to app/Domains/Sales/Application/InvoiceBalanceService.php index edb46918..186eb4cd 100644 --- a/app/Services/Document/InvoiceBalanceService.php +++ b/app/Domains/Sales/Application/InvoiceBalanceService.php @@ -1,8 +1,8 @@ getInvoicePayload(); - - if ($request->has('invoiceSend')) { - $data['status'] = Invoice::STATUS_SENT; - } - - $invoice = Invoice::create($data); + /** + * @param array $attributes + * @param array> $items + * @param array>|null $taxes + */ + public function create( + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Invoice { + $invoice = Invoice::create($attributes); $serial = (new SerialNumberService) ->setModel($invoice) @@ -47,23 +56,23 @@ class InvoiceService $invoice->sequence_number = $serial->nextSequenceNumber; $invoice->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $invoice->unique_hash = Hashids::connection(Invoice::class)->encode($invoice->id); + $invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id); $invoice->save(); - $this->documentItemService->createItems($invoice, $request->items); + $this->documentItemService->createItems($invoice, $items); - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + $companyCurrency = CompanySetting::getSetting('currency', $invoice->company_id); - if ((string) $data['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($invoice); + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($invoice); } - if ($request->has('taxes') && (! empty($request->taxes))) { - $this->documentItemService->createTaxes($invoice, $request->taxes); + if ($taxes) { + $this->documentItemService->createTaxes($invoice, $taxes); } - if ($request->customFields) { - $invoice->addCustomFields($request->customFields); + if ($customFields) { + $this->customFieldValueWriter->attach($invoice, $customFields); } return Invoice::with([ @@ -73,60 +82,64 @@ class InvoiceService 'customer', 'taxes', 'creditNotes', - ])->find($invoice->id); + ])->findOrFail($invoice->id); } /** * @throws ValidationException */ - public function update(Invoice $invoice, Request $request): Invoice - { + public function update( + Invoice $invoice, + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): Invoice { $serial = (new SerialNumberService) ->setModel($invoice) ->setCompany($invoice->company_id) - ->setCustomer($request->customer_id) + ->setCustomer($attributes['customer_id']) ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setModelObject($invoice->id) ->setNextNumbers(); - $data = $request->getInvoicePayload(); $oldTotal = $invoice->total; $totalPaidAmount = $invoice->total - $invoice->due_amount; - if ($totalPaidAmount > 0 && $invoice->customer_id !== $request->customer_id) { + if ($totalPaidAmount > 0 && (int) $invoice->customer_id !== (int) $attributes['customer_id']) { throw ValidationException::withMessages([ 'customer_id' => ['customer_cannot_be_changed_after_payment_is_added'], ]); } - if ($data['total'] >= 0 && $data['total'] < $totalPaidAmount) { + if ($attributes['total'] >= 0 && $attributes['total'] < $totalPaidAmount) { throw ValidationException::withMessages([ 'total' => ['total_invoice_amount_must_be_more_than_paid_amount'], ]); } - if ($oldTotal != $data['total']) { - $oldTotal = (int) round($data['total']) - (int) $oldTotal; + if ($oldTotal != $attributes['total']) { + $oldTotal = (int) round($attributes['total']) - (int) $oldTotal; } else { $oldTotal = 0; } - $data['due_amount'] = ($invoice->due_amount + $oldTotal); - $data['base_due_amount'] = $data['due_amount'] * $data['exchange_rate']; - $data['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; + $attributes['due_amount'] = ($invoice->due_amount + $oldTotal); + $attributes['base_due_amount'] = $attributes['due_amount'] * $attributes['exchange_rate']; + $attributes['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; - $invoice->update($data); + $invoice->update($attributes); - $statusData = $invoice->getInvoiceStatusByAmount($data['due_amount']); + $statusData = $invoice->getInvoiceStatusByAmount($attributes['due_amount']); if (! empty($statusData)) { $invoice->update($statusData); } - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + $companyCurrency = CompanySetting::getSetting('currency', $invoice->company_id); - if ((string) $data['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($invoice); + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($invoice); } $invoice->items->map(function ($item) { @@ -140,14 +153,14 @@ class InvoiceService $invoice->items()->delete(); $invoice->taxes()->delete(); - $this->documentItemService->createItems($invoice, $request->items); + $this->documentItemService->createItems($invoice, $items); - if ($request->has('taxes') && (! empty($request->taxes))) { - $this->documentItemService->createTaxes($invoice, $request->taxes); + if ($taxes) { + $this->documentItemService->createTaxes($invoice, $taxes); } - if ($request->customFields) { - $invoice->updateCustomFields($request->customFields); + if ($customFields) { + $this->customFieldValueWriter->update($invoice, $customFields); } return Invoice::with([ @@ -157,7 +170,7 @@ class InvoiceService 'customer', 'taxes', 'creditNotes', - ])->find($invoice->id); + ])->findOrFail($invoice->id); } public function delete(Collection $ids): bool @@ -236,20 +249,9 @@ class InvoiceService { $data = $this->sendInvoiceData($invoice, $data); - CompanyMailConfigService::apply($invoice->company_id); + $this->mailConfigurator->applyCompanyConfig($invoice->company_id); - $mail = \Mail::to($data['to']); - if (! empty($data['cc'])) { - $mail->cc($data['cc']); - } - if (! empty($data['bcc'])) { - $mail->bcc($data['bcc']); - } - // A credit note travels through the same send channel as the invoice it - // reverses; only the template (and its EmailLog entry) differs. - $mail->send($invoice->isCreditNote() - ? new SendCreditNoteMail($data) - : new SendInvoiceMail($data)); + $this->invoiceEmailSender->send($data, $invoice->isCreditNote()); if ($invoice->status == Invoice::STATUS_DRAFT) { $invoice->status = Invoice::STATUS_SENT; @@ -263,7 +265,7 @@ class InvoiceService ]; } - public function getPdfData(Invoice $invoice) + public function getPdfData(Invoice $invoice): mixed { $taxes = collect(); @@ -382,7 +384,7 @@ class InvoiceService 'sales_tax_address_type' => $invoice->sales_tax_address_type, ]); - $newInvoice->unique_hash = Hashids::connection(Invoice::class)->encode($newInvoice->id); + $newInvoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($newInvoice->id); $newInvoice->save(); $invoice->load('items.taxes'); @@ -402,7 +404,7 @@ class InvoiceService ]; } - $newInvoice->addCustomFields($customFields); + $this->customFieldValueWriter->attach($newInvoice, $customFields); } return $newInvoice; @@ -451,7 +453,7 @@ class InvoiceService 'sales_tax_address_type' => $invoice->sales_tax_address_type, ]); - $estimate->unique_hash = Hashids::connection(Estimate::class)->encode($estimate->id); + $estimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($estimate->id); $estimate->save(); $this->documentItemService->createItems($estimate, $invoice->items->toArray()); @@ -470,7 +472,7 @@ class InvoiceService ]; } - $estimate->addCustomFields($customFields); + $this->customFieldValueWriter->attach($estimate, $customFields); } return $estimate; diff --git a/app/Services/Document/RecurringInvoiceService.php b/app/Domains/Sales/Application/RecurringInvoiceService.php similarity index 78% rename from app/Services/Document/RecurringInvoiceService.php rename to app/Domains/Sales/Application/RecurringInvoiceService.php index 661523aa..7a6d5a93 100644 --- a/app/Services/Document/RecurringInvoiceService.php +++ b/app/Domains/Sales/Application/RecurringInvoiceService.php @@ -1,15 +1,16 @@ getRecurringInvoicePayload()); + /** + * @param array $attributes + * @param array> $items + * @param array>|null $taxes + */ + public function create( + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): RecurringInvoice { + $recurringInvoice = RecurringInvoice::create($attributes); - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); + $companyCurrency = CompanySetting::getSetting('currency', $recurringInvoice->company_id); if ((string) $recurringInvoice['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($recurringInvoice); + $this->exchangeRateRecorder->record($recurringInvoice); } - $this->createItems($recurringInvoice, $request->items); + $this->createItems($recurringInvoice, $items); - if ($request->has('taxes') && (! empty($request->taxes))) { - $this->createTaxes($recurringInvoice, $request->taxes); + if ($taxes) { + $this->createTaxes($recurringInvoice, $taxes); } - if ($request->customFields) { - $recurringInvoice->addCustomFields($request->customFields); + if ($customFields) { + $this->customFieldValueWriter->attach($recurringInvoice, $customFields); } return $recurringInvoice; } - public function update(RecurringInvoice $recurringInvoice, RecurringInvoiceRequest $request): RecurringInvoice - { - $data = $request->getRecurringInvoicePayload(); + public function update( + RecurringInvoice $recurringInvoice, + array $attributes, + array $items, + ?array $taxes = null, + ?iterable $customFields = null, + ): RecurringInvoice { + $recurringInvoice->update($attributes); - $recurringInvoice->update($data); + $companyCurrency = CompanySetting::getSetting('currency', $recurringInvoice->company_id); - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); - - if ((string) $data['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($recurringInvoice); + if ((string) $attributes['currency_id'] !== $companyCurrency) { + $this->exchangeRateRecorder->record($recurringInvoice); } $recurringInvoice->items()->delete(); - $this->createItems($recurringInvoice, $request->items); + $this->createItems($recurringInvoice, $items); $recurringInvoice->taxes()->delete(); - if ($request->has('taxes') && (! empty($request->taxes))) { - $this->createTaxes($recurringInvoice, $request->taxes); + if ($taxes) { + $this->createTaxes($recurringInvoice, $taxes); } - if ($request->customFields) { - $recurringInvoice->updateCustomFields($request->customFields); + if ($customFields) { + $this->customFieldValueWriter->update($recurringInvoice, $customFields); } return $recurringInvoice; @@ -171,7 +186,7 @@ class RecurringInvoiceService $newInvoice['base_tax'] = $recurringInvoice->exchange_rate * $recurringInvoice->tax; $newInvoice['base_total'] = $recurringInvoice->exchange_rate * $recurringInvoice->total; $invoice = Invoice::create($newInvoice); - $invoice->unique_hash = Hashids::connection(Invoice::class)->encode($invoice->id); + $invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id); $invoice->save(); $recurringInvoice->load('items.taxes'); @@ -191,7 +206,7 @@ class RecurringInvoiceService ]; } - $invoice->addCustomFields($customField); + $this->customFieldValueWriter->attach($invoice, $customField); } if ($recurringInvoice->send_automatically == true) { diff --git a/app/Services/Document/SerialNumberService.php b/app/Domains/Sales/Application/SerialNumberService.php similarity index 96% rename from app/Services/Document/SerialNumberService.php rename to app/Domains/Sales/Application/SerialNumberService.php index ab9519e7..aa648322 100644 --- a/app/Services/Document/SerialNumberService.php +++ b/app/Domains/Sales/Application/SerialNumberService.php @@ -1,9 +1,9 @@ model)); $settingKey = $this->settingKey ?: $modelName.'_number_format'; $companyId = $this->company; - if (request()->has('format')) { - $format = request()->get('format'); - } else { + if ($format === null) { $format = CompanySetting::getSetting( $settingKey, $companyId diff --git a/app/Console/Commands/CheckEstimateStatus.php b/app/Domains/Sales/Console/CheckEstimateStatus.php similarity index 93% rename from app/Console/Commands/CheckEstimateStatus.php rename to app/Domains/Sales/Console/CheckEstimateStatus.php index da7afe5d..55a5aed9 100644 --- a/app/Console/Commands/CheckEstimateStatus.php +++ b/app/Domains/Sales/Console/CheckEstimateStatus.php @@ -1,8 +1,8 @@ $data */ + public function send(array $data): void; +} diff --git a/app/Domains/Sales/Contracts/EstimatePdfDataProvider.php b/app/Domains/Sales/Contracts/EstimatePdfDataProvider.php new file mode 100644 index 00000000..3091afb4 --- /dev/null +++ b/app/Domains/Sales/Contracts/EstimatePdfDataProvider.php @@ -0,0 +1,10 @@ + $data */ + public function send(array $data, bool $creditNote): void; +} diff --git a/app/Domains/Sales/Contracts/InvoicePdfDataProvider.php b/app/Domains/Sales/Contracts/InvoicePdfDataProvider.php new file mode 100644 index 00000000..6b7a68df --- /dev/null +++ b/app/Domains/Sales/Contracts/InvoicePdfDataProvider.php @@ -0,0 +1,10 @@ +authorize('create', Estimate::class); - $estimate = $this->estimateService->create($request); + $estimate = $this->estimateService->create( + attributes: $request->getEstimatePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); if ($request->has('estimateSend')) { $this->estimateService->send($estimate, $request->only(['title', 'body'])); @@ -66,7 +71,13 @@ class EstimatesController extends Controller { $this->authorize('update', $estimate); - $estimate = $this->estimateService->update($estimate, $request); + $estimate = $this->estimateService->update( + estimate: $estimate, + attributes: $request->getEstimatePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); GenerateEstimatePdfJob::dispatch($estimate, true); @@ -141,4 +152,11 @@ class EstimatesController extends Controller 'success' => true, ]); } + + private function customFields(EstimatesRequest $request): ?iterable + { + $customFields = $request->input('customFields'); + + return is_iterable($customFields) ? $customFields : null; + } } diff --git a/app/Http/Controllers/Company/Invoice/InvoiceTemplatesController.php b/app/Domains/Sales/Http/Controllers/Company/InvoiceTemplatesController.php similarity index 77% rename from app/Http/Controllers/Company/Invoice/InvoiceTemplatesController.php rename to app/Domains/Sales/Http/Controllers/Company/InvoiceTemplatesController.php index 8332afb5..fe64452e 100644 --- a/app/Http/Controllers/Company/Invoice/InvoiceTemplatesController.php +++ b/app/Domains/Sales/Http/Controllers/Company/InvoiceTemplatesController.php @@ -1,10 +1,10 @@ authorize('create', Invoice::class); - $invoice = $this->invoiceService->create($request); + $invoice = $this->invoiceService->create( + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); if ($request->has('invoiceSend')) { $this->invoiceService->send($invoice, $request->only(['subject', 'body'])); @@ -103,11 +108,17 @@ class InvoicesController extends Controller * @param Request $request * @return JsonResponse */ - public function update(Requests\InvoicesRequest $request, Invoice $invoice) + public function update(InvoicesRequest $request, Invoice $invoice) { $this->authorize('update', $invoice); - $invoice = $this->invoiceService->update($invoice, $request); + $invoice = $this->invoiceService->update( + invoice: $invoice, + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); GenerateInvoicePdfJob::dispatch($invoice, true); @@ -250,4 +261,11 @@ class InvoicesController extends Controller 'success' => true, ]); } + + private function customFields(InvoicesRequest $request): ?iterable + { + $customFields = $request->input('customFields'); + + return is_iterable($customFields) ? $customFields : null; + } } diff --git a/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceController.php b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceController.php similarity index 68% rename from app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceController.php rename to app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceController.php index 05121b2f..2bdf7f2c 100644 --- a/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceController.php +++ b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceController.php @@ -1,12 +1,12 @@ authorize('create', RecurringInvoice::class); - $recurringInvoice = $this->recurringInvoiceService->create($request); + $recurringInvoice = $this->recurringInvoiceService->create( + attributes: $request->getRecurringInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); return new RecurringInvoiceResource($recurringInvoice); } @@ -74,7 +79,13 @@ class RecurringInvoiceController extends Controller { $this->authorize('update', $recurringInvoice); - $this->recurringInvoiceService->update($recurringInvoice, $request); + $this->recurringInvoiceService->update( + recurringInvoice: $recurringInvoice, + attributes: $request->getRecurringInvoicePayload(), + items: $request->input('items'), + taxes: $request->has('taxes') ? $request->input('taxes') : null, + customFields: $this->customFields($request), + ); return new RecurringInvoiceResource($recurringInvoice); } @@ -99,4 +110,11 @@ class RecurringInvoiceController extends Controller 'success' => true, ]); } + + private function customFields(RecurringInvoiceRequest $request): ?iterable + { + $customFields = $request->input('customFields'); + + return is_iterable($customFields) ? $customFields : null; + } } diff --git a/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceFrequencyController.php b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceFrequencyController.php similarity index 74% rename from app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceFrequencyController.php rename to app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceFrequencyController.php index b98fea63..dbc58f48 100644 --- a/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceFrequencyController.php +++ b/app/Domains/Sales/Http/Controllers/Company/RecurringInvoiceFrequencyController.php @@ -1,9 +1,9 @@ setModel($invoice) ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) ->setModelObject($request->model_id) - ->getNextNumber(); + ->getNextNumber($request->input('format')); break; @@ -37,21 +37,21 @@ class SerialNumberController extends Controller ->setSettingKey('credit_note_number_format') ->setSequenceScope(['type' => Invoice::TYPE_CREDIT_NOTE]) ->setModelObject($request->model_id) - ->getNextNumber(); + ->getNextNumber($request->input('format')); break; case 'estimate': $nextNumber = $serial->setModel($estimate) ->setModelObject($request->model_id) - ->getNextNumber(); + ->getNextNumber($request->input('format')); break; case 'payment': $nextNumber = $serial->setModel($payment) ->setModelObject($request->model_id) - ->getNextNumber(); + ->getNextNumber($request->input('format')); break; diff --git a/app/Http/Controllers/CustomerPortal/Estimate/AcceptEstimateController.php b/app/Domains/Sales/Http/Controllers/CustomerPortal/AcceptEstimateController.php similarity index 74% rename from app/Http/Controllers/CustomerPortal/Estimate/AcceptEstimateController.php rename to app/Domains/Sales/Http/Controllers/CustomerPortal/AcceptEstimateController.php index 72dbf789..60c0d429 100644 --- a/app/Http/Controllers/CustomerPortal/Estimate/AcceptEstimateController.php +++ b/app/Domains/Sales/Http/Controllers/CustomerPortal/AcceptEstimateController.php @@ -1,11 +1,11 @@ has('preview')) { + return $this->invoiceService->getPdfData($invoice); + } + + return $invoice->getGeneratedPDFOrStream('invoice'); + } + + public function estimate(Request $request, Estimate $estimate) + { + if ($request->has('preview')) { + return $this->estimateService->getPdfData($estimate); + } + + return $estimate->getGeneratedPDFOrStream('estimate'); + } +} diff --git a/app/Http/Requests/ChangeInvoiceStatusRequest.php b/app/Domains/Sales/Http/Requests/ChangeInvoiceStatusRequest.php similarity index 88% rename from app/Http/Requests/ChangeInvoiceStatusRequest.php rename to app/Domains/Sales/Http/Requests/ChangeInvoiceStatusRequest.php index 534303ed..37e2e49f 100644 --- a/app/Http/Requests/ChangeInvoiceStatusRequest.php +++ b/app/Domains/Sales/Http/Requests/ChangeInvoiceStatusRequest.php @@ -1,8 +1,8 @@ $this->data['bcc'] ?? null, 'subject' => $this->data['subject'], 'body' => $this->data['body'], - 'mailable_type' => Invoice::class, + 'mailable_type' => ModelIdentityMap::aliasFor(Invoice::class), 'mailable_id' => $this->data['invoice']['id'], ]); - $log->token = Hashids::connection(EmailLog::class)->encode($log->id); + $log->token = Hashids::connection(HashidConnection::EmailLog->value)->encode($log->id); $log->save(); $this->data['url'] = route('invoice', ['email_log' => $log->token]); diff --git a/app/Mail/SendEstimateMail.php b/app/Domains/Sales/Mail/SendEstimateMail.php similarity index 79% rename from app/Mail/SendEstimateMail.php rename to app/Domains/Sales/Mail/SendEstimateMail.php index 6cff44e5..a4e25f4b 100644 --- a/app/Mail/SendEstimateMail.php +++ b/app/Domains/Sales/Mail/SendEstimateMail.php @@ -1,10 +1,12 @@ $this->data['bcc'] ?? null, 'subject' => $this->data['subject'], 'body' => $this->data['body'], - 'mailable_type' => Estimate::class, + 'mailable_type' => ModelIdentityMap::aliasFor(Estimate::class), 'mailable_id' => $this->data['estimate']['id'], ]); - $log->token = Hashids::connection(EmailLog::class)->encode($log->id); + $log->token = Hashids::connection(HashidConnection::EmailLog->value)->encode($log->id); $log->save(); $this->data['url'] = route('estimate', ['email_log' => $log->token]); diff --git a/app/Mail/SendInvoiceMail.php b/app/Domains/Sales/Mail/SendInvoiceMail.php similarity index 79% rename from app/Mail/SendInvoiceMail.php rename to app/Domains/Sales/Mail/SendInvoiceMail.php index 2f6bec6d..e9dccbab 100644 --- a/app/Mail/SendInvoiceMail.php +++ b/app/Domains/Sales/Mail/SendInvoiceMail.php @@ -1,10 +1,12 @@ $this->data['bcc'] ?? null, 'subject' => $this->data['subject'], 'body' => $this->data['body'], - 'mailable_type' => Invoice::class, + 'mailable_type' => ModelIdentityMap::aliasFor(Invoice::class), 'mailable_id' => $this->data['invoice']['id'], ]); - $log->token = Hashids::connection(EmailLog::class)->encode($log->id); + $log->token = Hashids::connection(HashidConnection::EmailLog->value)->encode($log->id); $log->save(); $this->data['url'] = route('invoice', ['email_log' => $log->token]); diff --git a/app/Models/Estimate.php b/app/Domains/Sales/Models/Estimate.php similarity index 92% rename from app/Models/Estimate.php rename to app/Domains/Sales/Models/Estimate.php index a815c945..fdb39d4c 100644 --- a/app/Models/Estimate.php +++ b/app/Domains/Sales/Models/Estimate.php @@ -1,13 +1,20 @@ morphMany('App\Models\EmailLog', 'mailable'); + return $this->morphMany(EmailLog::class, 'mailable'); } public function items(): HasMany @@ -216,7 +225,7 @@ class Estimate extends Model implements HasMedia public function getPDFData(): mixed { - return app(EstimateService::class)->getPdfData($this); + return app(EstimatePdfDataProvider::class)->getPdfData($this); } public function getCompanyAddress(): string|false diff --git a/app/Models/EstimateItem.php b/app/Domains/Sales/Models/EstimateItem.php similarity index 82% rename from app/Models/EstimateItem.php rename to app/Domains/Sales/Models/EstimateItem.php index 516e85d9..d98d5ba4 100644 --- a/app/Models/EstimateItem.php +++ b/app/Domains/Sales/Models/EstimateItem.php @@ -1,8 +1,10 @@ morphMany('App\Models\EmailLog', 'mailable'); + return $this->morphMany(EmailLog::class, 'mailable'); } public function items(): HasMany @@ -384,7 +396,7 @@ class Invoice extends Model implements HasMedia public function getPDFData(): mixed { - return app(InvoiceService::class)->getPdfData($this); + return app(InvoicePdfDataProvider::class)->getPdfData($this); } public function getEmailAttachmentSetting(): bool diff --git a/app/Models/InvoiceItem.php b/app/Domains/Sales/Models/InvoiceItem.php similarity index 90% rename from app/Models/InvoiceItem.php rename to app/Domains/Sales/Models/InvoiceItem.php index 39755414..dc4ecbde 100644 --- a/app/Models/InvoiceItem.php +++ b/app/Domains/Sales/Models/InvoiceItem.php @@ -1,8 +1,10 @@ app->bind(EstimatePdfDataProvider::class, EstimateService::class); + $this->app->bind(InvoicePdfDataProvider::class, InvoiceService::class); + $this->app->bind(DocumentExchangeRateRecorder::class, MoneyDocumentExchangeRateRecorder::class); + $this->app->bind(EstimateEmailSender::class, LaravelEstimateEmailSender::class); + $this->app->bind(InvoiceEmailSender::class, LaravelInvoiceEmailSender::class); + } + + public function boot(): void + { + $this->commands([ + CheckEstimateStatus::class, + CheckInvoiceStatus::class, + ]); + + Gate::policy(Estimate::class, EstimatePolicy::class); + Gate::policy(Invoice::class, InvoicePolicy::class); + Gate::policy(RecurringInvoice::class, RecurringInvoicePolicy::class); + Gate::define('send invoice', [InvoicePolicy::class, 'send']); + Gate::define('create credit note', [CreditNotePolicy::class, 'create']); + Gate::define('send estimate', [EstimatePolicy::class, 'send']); + Gate::define('delete multiple invoices', [InvoicePolicy::class, 'deleteMultiple']); + Gate::define('delete multiple estimates', [EstimatePolicy::class, 'deleteMultiple']); + Gate::define('delete multiple recurring invoices', [RecurringInvoicePolicy::class, 'deleteMultiple']); + } +} diff --git a/app/Domains/Sales/routes/company.php b/app/Domains/Sales/routes/company.php new file mode 100644 index 00000000..16ce67a0 --- /dev/null +++ b/app/Domains/Sales/routes/company.php @@ -0,0 +1,36 @@ +name('invoice'); +Route::get('/estimates/{email_log:token}', [EstimatePdfController::class, 'getEstimate']); +Route::get('/estimates/view/{email_log:token}', [EstimatePdfController::class, 'getPdf'])->name('estimate'); diff --git a/app/Http/Controllers/Company/Settings/TaxTypesController.php b/app/Domains/Taxation/Http/Controllers/TaxTypesController.php similarity index 89% rename from app/Http/Controllers/Company/Settings/TaxTypesController.php rename to app/Domains/Taxation/Http/Controllers/TaxTypesController.php index 0615dbdd..23ccded1 100644 --- a/app/Http/Controllers/Company/Settings/TaxTypesController.php +++ b/app/Domains/Taxation/Http/Controllers/TaxTypesController.php @@ -1,11 +1,11 @@ authorize('manage pdf config'); - - $drivers = [ - 'dompdf', - 'gotenberg', - ]; - - return response()->json($drivers); - } - - /** - * Return the PDF settings - * - * @throws AuthorizationException - */ - public function getEnvironment(): JsonResponse - { - $this->authorize('manage pdf config'); - - $pdfSettings = Setting::getSettings(array_merge( - ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa'], - self::PAGE_SETTINGS, - self::PAGE_BOOLEANS, - )); - - $config = [ - 'pdf_driver' => $pdfSettings['pdf_driver'] ?? config('pdf.driver'), - 'gotenberg_host' => $pdfSettings['gotenberg_host'] ?? config('pdf.connections.gotenberg.host'), - 'gotenberg_pdfa' => $pdfSettings['gotenberg_pdfa'] ?? config('pdf.connections.gotenberg.pdfa') ?? '', - ]; - - // Page geometry applies to whichever driver is selected, so it is always - // returned rather than nested under a driver branch. - foreach (self::PAGE_SETTINGS as $setting) { - $config[$setting] = $pdfSettings[$setting] ?? config(self::configKeyFor($setting)); - } - - foreach (self::PAGE_BOOLEANS as $setting) { - $config[$setting] = filter_var( - $pdfSettings[$setting] ?? config(self::configKeyFor($setting)), - FILTER_VALIDATE_BOOLEAN - ); - } - - return response()->json($config); - } - - /** - * Saves the settings - * - * @throws AuthorizationException - */ - public function saveEnvironment(PDFConfigurationRequest $request): JsonResponse - { - $this->authorize('manage pdf config'); - - // Prepare PDF settings for database storage - $pdfSettings = $this->preparePDFSettingsForDatabase($request); - - // Save PDF settings to database - Setting::setSettings($pdfSettings); - - return response()->json([ - 'success' => 'pdf_variables_save_successfully', - ]); - } - - /** - * Prepare PDF settings for database storage - */ - private function preparePDFSettingsForDatabase(PDFConfigurationRequest $request): array - { - $driver = $request->get('pdf_driver'); - - $settings = ['pdf_driver' => $driver]; - - // Page geometry is saved for every driver: switching between them should - // not lose the paper size, which is what happened while it was a - // Gotenberg-only setting. - foreach (self::PAGE_SETTINGS as $setting) { - $settings[$setting] = $request->get($setting); - } - - // Only written when the form actually submitted it. Page numbers are a - // Gotenberg capability and the dompdf form does not render the control, - // so an unconditional write would clear the operator's choice every time - // they saved from the other driver. - foreach (self::PAGE_BOOLEANS as $setting) { - if ($request->has($setting)) { - $settings[$setting] = $request->boolean($setting) ? '1' : '0'; - } - } - - if ($driver === 'gotenberg') { - $settings['gotenberg_host'] = $request->get('gotenberg_host'); - $settings['gotenberg_pdfa'] = $request->get('gotenberg_pdfa') ?? ''; - } - - return $settings; - } - - /** - * Maps a settings key onto its config counterpart, e.g. - * `pdf_margin_top` -> `pdf.page.margin_top`. - */ - private static function configKeyFor(string $setting): string - { - return 'pdf.page.'.substr($setting, strlen('pdf_')); - } -} diff --git a/app/Http/Controllers/Company/Auth/ConfirmPasswordController.php b/app/Http/Controllers/Company/Auth/ConfirmPasswordController.php deleted file mode 100644 index ac26a4f1..00000000 --- a/app/Http/Controllers/Company/Auth/ConfirmPasswordController.php +++ /dev/null @@ -1,40 +0,0 @@ -middleware('auth'); - } -} diff --git a/app/Http/Controllers/Company/Auth/RegisterController.php b/app/Http/Controllers/Company/Auth/RegisterController.php deleted file mode 100644 index 5a222b63..00000000 --- a/app/Http/Controllers/Company/Auth/RegisterController.php +++ /dev/null @@ -1,74 +0,0 @@ -middleware('guest'); - } - - /** - * Get a validator for an incoming registration request. - * - * @return \Illuminate\Contracts\Validation\Validator - */ - protected function validator(array $data) - { - return Validator::make($data, [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], - 'password' => ['required', 'string', 'min:8', 'confirmed'], - ]); - } - - /** - * Create a new user instance after a valid registration. - * - * @return \App\User - */ - protected function create(array $data) - { - $user = User::create([ - 'name' => $data['name'], - 'email' => $data['email'], - 'password' => $data['password'], - ]); - - $user->setSettings(['language' => 'default']); - - return $user; - } -} diff --git a/app/Http/Controllers/Company/Auth/VerificationController.php b/app/Http/Controllers/Company/Auth/VerificationController.php deleted file mode 100644 index 68ea2302..00000000 --- a/app/Http/Controllers/Company/Auth/VerificationController.php +++ /dev/null @@ -1,42 +0,0 @@ -middleware('auth'); - $this->middleware('signed')->only('verify'); - $this->middleware('throttle:6,1')->only('verify', 'resend'); - } -} diff --git a/app/Http/Controllers/Company/Config/FiscalYearsController.php b/app/Http/Controllers/Company/Config/FiscalYearsController.php deleted file mode 100644 index b2f0f8ad..00000000 --- a/app/Http/Controllers/Company/Config/FiscalYearsController.php +++ /dev/null @@ -1,22 +0,0 @@ -json([ - 'fiscal_years' => config('invoiceshelf.fiscal_years'), - ]); - } -} diff --git a/app/Http/Controllers/Company/Config/LanguagesController.php b/app/Http/Controllers/Company/Config/LanguagesController.php deleted file mode 100644 index 6bcaa1bd..00000000 --- a/app/Http/Controllers/Company/Config/LanguagesController.php +++ /dev/null @@ -1,22 +0,0 @@ -json([ - 'languages' => config('invoiceshelf.languages'), - ]); - } -} diff --git a/app/Http/Controllers/Company/Config/RetrospectiveEditsController.php b/app/Http/Controllers/Company/Config/RetrospectiveEditsController.php deleted file mode 100644 index fa2795c3..00000000 --- a/app/Http/Controllers/Company/Config/RetrospectiveEditsController.php +++ /dev/null @@ -1,22 +0,0 @@ -json([ - 'retrospective_edits' => config('invoiceshelf.retrospective_edits'), - ]); - } -} diff --git a/app/Http/Controllers/Company/Customer/CustomerStatsController.php b/app/Http/Controllers/Company/Customer/CustomerStatsController.php deleted file mode 100644 index c646ee20..00000000 --- a/app/Http/Controllers/Company/Customer/CustomerStatsController.php +++ /dev/null @@ -1,37 +0,0 @@ -authorize('view', $customer); - - $chartData = $this->customerService->getStats( - $customer, - $request->header('company'), - $request->has('previous_year') - ); - - $customer = Customer::find($customer->id); - $this->customerStatementService->hydrateAccountSummaries([$customer]); - - return (new CustomerResource($customer)) - ->additional(['meta' => [ - 'chartData' => $chartData, - ]]); - } -} diff --git a/app/Http/Controllers/Company/Settings/CompanyController.php b/app/Http/Controllers/Company/Settings/CompanyController.php deleted file mode 100644 index 781082f9..00000000 --- a/app/Http/Controllers/Company/Settings/CompanyController.php +++ /dev/null @@ -1,53 +0,0 @@ -header('company')); - - $this->authorize('manage company', $company); - - $company->update($request->getCompanyPayload()); - - $company->address()->updateOrCreate(['company_id' => $company->id], $request->address); - - return new CompanyResource($company); - } - - public function uploadCompanyLogo(CompanyLogoRequest $request) - { - $company = Company::find($request->header('company')); - - $this->authorize('manage company', $company); - - $data = json_decode($request->company_logo); - - if (isset($request->is_company_logo_removed) && (bool) $request->is_company_logo_removed) { - $company->clearMediaCollection('logo'); - } - if ($data) { - $company = Company::find($request->header('company')); - - if ($company) { - $company->clearMediaCollection('logo'); - - $company->addMediaFromBase64($data->data) - ->usingFileName($data->name) - ->toMediaCollection('logo'); - } - } - - return response()->json([ - 'success' => true, - ]); - } -} diff --git a/app/Http/Controllers/CustomerPortal/General/DashboardController.php b/app/Http/Controllers/CustomerPortal/General/DashboardController.php deleted file mode 100644 index 6a900d4e..00000000 --- a/app/Http/Controllers/CustomerPortal/General/DashboardController.php +++ /dev/null @@ -1,48 +0,0 @@ -user(); - - $amountDue = Invoice::whereCustomer($user->id) - ->where('status', '<>', 'DRAFT') - ->sum('due_amount'); - // Counts issued invoices only; a credit note is a reversal document, - // not another invoice the customer received. - $invoiceCount = Invoice::whereCustomer($user->id) - ->where('type', Invoice::TYPE_INVOICE) - ->where('status', '<>', 'DRAFT') - ->count(); - $estimatesCount = Estimate::whereCustomer($user->id) - ->where('status', '<>', 'DRAFT') - ->count(); - $paymentCount = Payment::whereCustomer($user->id) - ->count(); - - return response()->json([ - 'due_amount' => $amountDue, - 'recentInvoices' => Invoice::whereCustomer($user->id)->where('status', '<>', 'DRAFT')->take(5)->latest()->get(), - 'recentEstimates' => Estimate::whereCustomer($user->id)->where('status', '<>', 'DRAFT')->take(5)->latest()->get(), - 'invoice_count' => $invoiceCount, - 'estimate_count' => $estimatesCount, - 'payment_count' => $paymentCount, - ]); - } -} diff --git a/app/Http/Controllers/CustomerPortal/General/ProfileController.php b/app/Http/Controllers/CustomerPortal/General/ProfileController.php deleted file mode 100644 index 56d8aab6..00000000 --- a/app/Http/Controllers/CustomerPortal/General/ProfileController.php +++ /dev/null @@ -1,49 +0,0 @@ -user(); - - $customer->update($request->validated()); - - if (isset($request->is_customer_avatar_removed) && (bool) $request->is_customer_avatar_removed) { - $customer->clearMediaCollection('customer_avatar'); - } - if ($customer && $request->hasFile('customer_avatar')) { - $customer->clearMediaCollection('customer_avatar'); - - $customer->addMediaFromRequest('customer_avatar') - ->toMediaCollection('customer_avatar'); - } - - if ($request->billing !== null) { - $customer->shippingAddress()->delete(); - $customer->addresses()->create($request->getShippingAddress()); - } - - if ($request->shipping !== null) { - $customer->billingAddress()->delete(); - $customer->addresses()->create($request->getBillingAddress()); - } - - return new CustomerResource($customer); - } - - public function getUser(Request $request) - { - $customer = Auth::guard('customer')->user(); - - return new CustomerResource($customer); - } -} diff --git a/app/Http/Controllers/Pdf/DocumentPdfController.php b/app/Http/Controllers/Pdf/DocumentPdfController.php deleted file mode 100644 index 6222c18c..00000000 --- a/app/Http/Controllers/Pdf/DocumentPdfController.php +++ /dev/null @@ -1,52 +0,0 @@ -has('preview')) { - return $this->invoiceService->getPdfData($invoice); - } - - return $invoice->getGeneratedPDFOrStream('invoice'); - } - - public function estimate(Request $request, Estimate $estimate) - { - if ($request->has('preview')) { - return $this->estimateService->getPdfData($estimate); - } - - return $estimate->getGeneratedPDFOrStream('estimate'); - } - - public function payment(Request $request, Payment $payment) - { - if ($request->has('preview')) { - // Through the service, so the preview gets the same shared data and - // the same custom-override resolution as the rendered receipt. This - // used to name the built-in view directly, so a preview ignored an - // override and rendered with no data at all. - return $this->paymentService->getPdfData($payment); - } - - return $payment->getGeneratedPDFOrStream('payment'); - } -} diff --git a/app/Http/Requests/Request.php b/app/Http/Requests/Request.php deleted file mode 100644 index 76b2ffd4..00000000 --- a/app/Http/Requests/Request.php +++ /dev/null @@ -1,10 +0,0 @@ - [ - 'required', - 'regex:/^[\.\/\w\-]+$/', - ], - 'module' => [ - 'nullable', - 'string', - ], - 'module_name' => [ - 'required_without:module', - 'string', - ], - ]; - } -} diff --git a/app/Http/Resources/AddressCollection.php b/app/Http/Resources/AddressCollection.php deleted file mode 100644 index 840663fb..00000000 --- a/app/Http/Resources/AddressCollection.php +++ /dev/null @@ -1,19 +0,0 @@ -old, '<=')) { - return true; - } - - return false; - } -} diff --git a/app/Platform/Ai/AiServiceProvider.php b/app/Platform/Ai/AiServiceProvider.php new file mode 100644 index 00000000..fb46ef5d --- /dev/null +++ b/app/Platform/Ai/AiServiceProvider.php @@ -0,0 +1,107 @@ +user(); + $companyId = $request->header('company') ?? 'noop'; + $key = $user ? "{$user->id}:{$companyId}" : $request->ip(); + + return Limit::perMinute(30)->by($key); + }); + + Registry::registerAiDriver('openrouter', [ + 'class' => OpenRouterDriver::class, + 'label' => 'settings.ai.openrouter', + 'website' => 'https://openrouter.ai', + 'default_base_url' => 'https://openrouter.ai/api/v1', + 'supported_roles' => ['chat', 'text_generation'], + 'suggested_models' => [ + ['value' => 'anthropic/claude-sonnet-4.6', 'label' => 'Anthropic Claude Sonnet 4.6'], + ['value' => 'anthropic/claude-haiku-4.5', 'label' => 'Anthropic Claude Haiku 4.5'], + ['value' => 'anthropic/claude-opus-4.6', 'label' => 'Anthropic Claude Opus 4.6'], + ['value' => 'openai/gpt-5.4', 'label' => 'OpenAI GPT-5.4'], + ['value' => 'openai/gpt-5.4-mini', 'label' => 'OpenAI GPT-5.4 mini'], + ['value' => 'google/gemini-3.1-pro-preview', 'label' => 'Google Gemini 3.1 Pro (preview)'], + ['value' => 'google/gemini-3.1-flash-lite-preview', 'label' => 'Google Gemini 3.1 Flash Lite (preview)'], + ['value' => 'z-ai/glm-5.1', 'label' => 'Z.AI GLM 5.1'], + ['value' => 'z-ai/glm-4.7-flash', 'label' => 'Z.AI GLM 4.7 Flash'], + ], + 'config_fields' => [ + [ + 'key' => 'base_url', + 'type' => 'text', + 'label' => 'settings.ai.base_url', + 'default' => 'https://openrouter.ai/api/v1', + ], + ], + ]); + } + + public function register(): void + { + $this->app->singleton(AiToolRegistry::class, function (Application $app): AiToolRegistry { + $registry = new AiToolRegistry; + + // Built-in read-only tools (order is presentation-only; the LLM picks). + $registry->register(new SearchInvoicesTool); + $registry->register(new GetInvoiceTool); + $registry->register(new ListOverdueInvoicesTool); + $registry->register(new SearchCustomersTool); + $registry->register(new GetCustomerTool); + $registry->register(new ListRecentPaymentsTool); + $registry->register(new SearchItemsTool); + $registry->register(new ListExpenseCategoriesTool); + $registry->register(new GetCompanyStatsTool); + + // Ranking tools — group-by aggregates the individual-record + // tools above can't express. + $registry->register(new RankTopCustomersTool); + $registry->register(new RankTopItemsTool); + $registry->register(new RankExpenseCategoriesTool); + + return $registry; + }); + } +} diff --git a/app/Services/Ai/AiAssistantService.php b/app/Platform/Ai/Application/AiAssistantService.php similarity index 97% rename from app/Services/Ai/AiAssistantService.php rename to app/Platform/Ai/Application/AiAssistantService.php index a0c85e91..ac4b0e66 100644 --- a/app/Services/Ai/AiAssistantService.php +++ b/app/Platform/Ai/Application/AiAssistantService.php @@ -1,15 +1,14 @@ isSuperAdmin(); + } + + /** + * Feature configuration applies the instance and company kill switches. + */ + public function use(User $user): bool + { + return true; + } +} diff --git a/app/Policies/AiConversationPolicy.php b/app/Platform/Ai/Policies/AiConversationPolicy.php similarity index 89% rename from app/Policies/AiConversationPolicy.php rename to app/Platform/Ai/Policies/AiConversationPolicy.php index 34e90a1a..9523e190 100644 --- a/app/Policies/AiConversationPolicy.php +++ b/app/Platform/Ai/Policies/AiConversationPolicy.php @@ -1,9 +1,9 @@ group(function () { + Route::post('/ai/chat', ChatController::class); + Route::get('/ai/conversations', [ConversationController::class, 'index']); + Route::get('/ai/conversations/{id}', [ConversationController::class, 'show']); + Route::patch('/ai/conversations/{id}', [ConversationController::class, 'update']); + Route::delete('/ai/conversations/{id}', [ConversationController::class, 'destroy']); + Route::post('/ai/generate', GenerationController::class); +}); diff --git a/app/Platform/Ai/routes/installer.php b/app/Platform/Ai/routes/installer.php new file mode 100644 index 00000000..11cd2706 --- /dev/null +++ b/app/Platform/Ai/routes/installer.php @@ -0,0 +1,7 @@ + ModelIdentityMap::aliasFor($mailable::class), + 'mailable_id' => $mailable->getKey(), + ]); + + $log->token = Hashids::connection(HashidConnection::EmailLog->value)->encode($log->id); + $log->save(); + + return $log->token; + } +} diff --git a/app/Services/Mail/MailConfigurationService.php b/app/Platform/Mail/Application/MailConfigurationService.php similarity index 98% rename from app/Services/Mail/MailConfigurationService.php rename to app/Platform/Mail/Application/MailConfigurationService.php index 3a4db7c7..1999e9df 100644 --- a/app/Services/Mail/MailConfigurationService.php +++ b/app/Platform/Mail/Application/MailConfigurationService.php @@ -1,9 +1,10 @@ 'required', ]); - CompanyMailConfigService::apply($request->header('company')); + $this->mailConfigurationService->applyCompanyConfig($request->header('company')); Mail::to($request->to)->send(new TestMail($request->subject, $request->message)); diff --git a/app/Http/Requests/CompanyMailConfigurationRequest.php b/app/Platform/Mail/Http/Requests/CompanyMailConfigurationRequest.php similarity index 89% rename from app/Http/Requests/CompanyMailConfigurationRequest.php rename to app/Platform/Mail/Http/Requests/CompanyMailConfigurationRequest.php index 44c4a3ff..8bfe9003 100644 --- a/app/Http/Requests/CompanyMailConfigurationRequest.php +++ b/app/Platform/Mail/Http/Requests/CompanyMailConfigurationRequest.php @@ -1,8 +1,8 @@ app->singleton(MailConfigurationService::class); + $this->app->bind( + MailConfigurator::class, + fn (Application $app): MailConfigurator => $app->make(MailConfigurationService::class), + ); + $this->app->bind(EmailLogWriter::class, EloquentEmailLogWriter::class); + } + + public function boot(): void + { + Gate::define('manage email config', [MailAccessPolicy::class, 'manageConfiguration']); + } +} diff --git a/app/Mail/TestMail.php b/app/Platform/Mail/Mailables/TestMail.php similarity index 94% rename from app/Mail/TestMail.php rename to app/Platform/Mail/Mailables/TestMail.php index 3cf04477..a9b87f60 100644 --- a/app/Mail/TestMail.php +++ b/app/Platform/Mail/Mailables/TestMail.php @@ -1,6 +1,6 @@ isSuperAdmin(); + } +} diff --git a/app/Platform/Mail/routes/company.php b/app/Platform/Mail/routes/company.php new file mode 100644 index 00000000..652b9613 --- /dev/null +++ b/app/Platform/Mail/routes/company.php @@ -0,0 +1,15 @@ + $settings */ + public function put(array $settings, int|string|null $companyId): void; + + public function deleteForModule(string $slug): void; +} diff --git a/app/Events/ModuleDisabledEvent.php b/app/Platform/Modules/Events/ModuleDisabledEvent.php similarity index 91% rename from app/Events/ModuleDisabledEvent.php rename to app/Platform/Modules/Events/ModuleDisabledEvent.php index 793fa94c..768e2830 100644 --- a/app/Events/ModuleDisabledEvent.php +++ b/app/Platform/Modules/Events/ModuleDisabledEvent.php @@ -1,6 +1,6 @@ authorize('manage module settings'); @@ -35,7 +37,7 @@ class ModuleSettingsController extends Controller $values = collect($schema->fields()) ->mapWithKeys(fn (array $field) => [ - $field['key'] => CompanySetting::getSetting( + $field['key'] => $this->settings->get( "module.{$slug}.{$field['key']}", $request->header('company') ) ?? $field['default'], @@ -75,7 +77,7 @@ class ModuleSettingsController extends Controller } if ($settingsToWrite !== []) { - CompanySetting::setSettings($settingsToWrite, $companyId); + $this->settings->put($settingsToWrite, $companyId); } return response()->json(['success' => true]); diff --git a/app/Http/Requests/InstallMarketplaceModuleRequest.php b/app/Platform/Modules/Http/Requests/InstallMarketplaceModuleRequest.php similarity index 94% rename from app/Http/Requests/InstallMarketplaceModuleRequest.php rename to app/Platform/Modules/Http/Requests/InstallMarketplaceModuleRequest.php index 7240c04d..8bc6b985 100644 --- a/app/Http/Requests/InstallMarketplaceModuleRequest.php +++ b/app/Platform/Modules/Http/Requests/InstallMarketplaceModuleRequest.php @@ -1,6 +1,6 @@ where('option', 'like', "module.{$slug}.%") + ->delete(); + } +} diff --git a/app/Services/Marketplace/CanonicalJson.php b/app/Platform/Modules/Marketplace/CanonicalJson.php similarity index 94% rename from app/Services/Marketplace/CanonicalJson.php rename to app/Platform/Modules/Marketplace/CanonicalJson.php index 9a3f95c4..d72f1d2f 100644 --- a/app/Services/Marketplace/CanonicalJson.php +++ b/app/Platform/Modules/Marketplace/CanonicalJson.php @@ -1,6 +1,6 @@ runDataCleanup($cleanup); $this->resetMigrations($module); - CompanySetting::query()->where('option', 'like', 'module.'.$manifest['slug'].'.%')->delete(); + $this->settings->deleteForModule($manifest['slug']); } $backup = $this->backUpRuntime($module, (string) $operation->id); diff --git a/app/Models/MarketplaceCredential.php b/app/Platform/Modules/Models/MarketplaceCredential.php similarity index 78% rename from app/Models/MarketplaceCredential.php rename to app/Platform/Modules/Models/MarketplaceCredential.php index a96c2548..dd24c8f9 100644 --- a/app/Models/MarketplaceCredential.php +++ b/app/Platform/Modules/Models/MarketplaceCredential.php @@ -1,11 +1,13 @@ app->bind(ModuleSettingsStore::class, EloquentModuleSettingsStore::class); + } + + public function boot(): void + { + $this->loadRoutesFrom(__DIR__.'/routes/api.php'); + $this->loadRoutesFrom(__DIR__.'/routes/web.php'); + + if ($this->app->runningInConsole()) { + $this->commands([ + InstallModuleCommand::class, + UninstallModuleCommand::class, + ]); + } + + Gate::define('manage modules', [ModulePolicy::class, 'manageModules']); + Gate::define('manage module settings', [ModulePolicy::class, 'manageSettings']); + } +} diff --git a/app/Platform/Modules/Policies/ModulePolicy.php b/app/Platform/Modules/Policies/ModulePolicy.php new file mode 100644 index 00000000..91ddbcae --- /dev/null +++ b/app/Platform/Modules/Policies/ModulePolicy.php @@ -0,0 +1,21 @@ +isSuperAdmin(); + } + + public function manageSettings(User $user): bool + { + return $user->isSuperAdmin() || $user->isOwner(); + } +} diff --git a/app/Services/Marketplace/DatabaseActivator.php b/app/Platform/Modules/Runtime/DatabaseActivator.php similarity index 98% rename from app/Services/Marketplace/DatabaseActivator.php rename to app/Platform/Modules/Runtime/DatabaseActivator.php index ae5c2c36..f6bb8558 100644 --- a/app/Services/Marketplace/DatabaseActivator.php +++ b/app/Platform/Modules/Runtime/DatabaseActivator.php @@ -1,6 +1,6 @@ prefix('api/v1') + ->group(function () { + Route::prefix('modules')->group(function () { + Route::get('/', [ModulesController::class, 'index']); + Route::get('/pairing', [MarketplacePairingController::class, 'status']); + Route::post('/pairing/start', [MarketplacePairingController::class, 'start']); + Route::post('/pairing/poll', [MarketplacePairingController::class, 'poll']); + Route::delete('/pairing', [MarketplacePairingController::class, 'disconnect']); + Route::get('/{module}', [ModulesController::class, 'show']); + Route::post('/{module}/enable', [ModulesController::class, 'enable']); + Route::post('/{module}/disable', [ModulesController::class, 'disable']); + Route::post('/{module}/uninstall', [ModuleInstallationController::class, 'uninstall']); + Route::post('/install', [ModuleInstallationController::class, 'install']); + Route::get('/{slug}/settings', [ModuleSettingsController::class, 'show']); + Route::put('/{slug}/settings', [ModuleSettingsController::class, 'update']); + }); + + Route::get('/company-modules', [CompanyModulesController::class, 'index']); + }); diff --git a/app/Platform/Modules/routes/web.php b/app/Platform/Modules/routes/web.php new file mode 100644 index 00000000..d78c0d8a --- /dev/null +++ b/app/Platform/Modules/routes/web.php @@ -0,0 +1,10 @@ +group(function () { + Route::get('/modules/styles/{style}', StyleController::class); + Route::get('/modules/scripts/{script}', ScriptController::class); +}); diff --git a/app/Platform/Operations/Application/RuntimeConfigurationService.php b/app/Platform/Operations/Application/RuntimeConfigurationService.php new file mode 100644 index 00000000..09f78a42 --- /dev/null +++ b/app/Platform/Operations/Application/RuntimeConfigurationService.php @@ -0,0 +1,33 @@ +mail, $this->pdf, $this->storage] as $configurator) { + try { + $configurator->applyGlobalConfig(); + } catch (\Exception) { + // Installation and migration commands can boot while the schema + // is incomplete. File configuration remains the safe fallback. + } + } + } +} diff --git a/app/Console/Commands/ResetApp.php b/app/Platform/Operations/Console/ResetApp.php similarity index 98% rename from app/Console/Commands/ResetApp.php rename to app/Platform/Operations/Console/ResetApp.php index 2e9036ba..bd587da6 100644 --- a/app/Console/Commands/ResetApp.php +++ b/app/Platform/Operations/Console/ResetApp.php @@ -1,6 +1,6 @@ json($results); diff --git a/app/Http/Controllers/Setup/FilePermissionsController.php b/app/Platform/Operations/Installation/Http/Controllers/FilePermissionsController.php similarity index 79% rename from app/Http/Controllers/Setup/FilePermissionsController.php rename to app/Platform/Operations/Installation/Http/Controllers/FilePermissionsController.php index 1db0787e..008458e6 100644 --- a/app/Http/Controllers/Setup/FilePermissionsController.php +++ b/app/Platform/Operations/Installation/Http/Controllers/FilePermissionsController.php @@ -1,9 +1,9 @@ json([ 'profile_complete' => 0, 'profile_language' => 'en', diff --git a/app/Http/Controllers/Setup/RequirementsController.php b/app/Platform/Operations/Installation/Http/Controllers/RequirementsController.php old mode 100755 new mode 100644 similarity index 82% rename from app/Http/Controllers/Setup/RequirementsController.php rename to app/Platform/Operations/Installation/Http/Controllers/RequirementsController.php index 6f96d581..ba9ede34 --- a/app/Http/Controllers/Setup/RequirementsController.php +++ b/app/Platform/Operations/Installation/Http/Controllers/RequirementsController.php @@ -1,9 +1,9 @@ name('install') + ->middleware(['redirect-if-installed']); + +// The Vue Router renders the wizard steps. This catch-all keeps deep links +// and hard refreshes inside the installation SPA from returning a 404. +Route::get('/installation/{vue?}', function () { + return view('app'); +})->where('vue', '.*') + ->middleware(['redirect-if-installed']); + +Route::post('/installation/session-login', SessionLoginController::class) + ->middleware(['redirect-if-installed', 'auth:sanctum']); diff --git a/app/Models/Setting.php b/app/Platform/Operations/Models/Setting.php similarity index 95% rename from app/Models/Setting.php rename to app/Platform/Operations/Models/Setting.php index f5581428..22377952 100644 --- a/app/Models/Setting.php +++ b/app/Platform/Operations/Models/Setting.php @@ -1,6 +1,6 @@ app->singleton(RuntimeConfigurationService::class); + } + + public function boot(RuntimeConfigurationService $runtimeConfiguration): void + { + $this->configureInstallWizardTokenAuth(); + + Gate::define('manage settings', [OperationsAccessPolicy::class, 'manage']); + Gate::define('manage update app', [OperationsAccessPolicy::class, 'manage']); + + $this->commands([ + ResetApp::class, + UpdateCommand::class, + ]); + + $runtimeConfiguration->apply(); + } + + private function configureInstallWizardTokenAuth(): void + { + Sanctum::authenticateAccessTokensUsing(function ($accessToken, bool $isValid): bool { + if (! $isValid) { + return false; + } + + $request = request(); + + if (! $request instanceof Request || ! $request->attributes->get('install_wizard', false)) { + return $isValid; + } + + return $accessToken->can(InstallWizardAuth::TOKEN_ABILITY); + }); + } +} diff --git a/app/Platform/Operations/Policies/OperationsAccessPolicy.php b/app/Platform/Operations/Policies/OperationsAccessPolicy.php new file mode 100644 index 00000000..1723d630 --- /dev/null +++ b/app/Platform/Operations/Policies/OperationsAccessPolicy.php @@ -0,0 +1,13 @@ +isSuperAdmin(); + } +} diff --git a/app/Traits/SiteApi.php b/app/Platform/Operations/Update/CallsReleaseServer.php similarity index 88% rename from app/Traits/SiteApi.php rename to app/Platform/Operations/Update/CallsReleaseServer.php index 4807d7a4..887bfb76 100644 --- a/app/Traits/SiteApi.php +++ b/app/Platform/Operations/Update/CallsReleaseServer.php @@ -1,13 +1,13 @@ group(function () { + Route::get('/check/update', [UpdateController::class, 'checkVersion']); + Route::post('/update/download', [UpdateController::class, 'download']); + Route::post('/update/unzip', [UpdateController::class, 'unzip']); + Route::post('/update/copy', [UpdateController::class, 'copy']); + Route::post('/update/delete', [UpdateController::class, 'delete']); + Route::post('/update/clean', [UpdateController::class, 'clean']); + Route::post('/update/migrate', [UpdateController::class, 'migrate']); + Route::post('/update/finish', [UpdateController::class, 'finish']); +}); diff --git a/app/Platform/Operations/routes/version.php b/app/Platform/Operations/routes/version.php new file mode 100644 index 00000000..bb31a2f5 --- /dev/null +++ b/app/Platform/Operations/routes/version.php @@ -0,0 +1,6 @@ +middleware('cron-job'); diff --git a/app/Services/FontService.php b/app/Platform/Pdf/Application/FontService.php similarity index 99% rename from app/Services/FontService.php rename to app/Platform/Pdf/Application/FontService.php index 475c16b5..6f60782f 100644 --- a/app/Services/FontService.php +++ b/app/Platform/Pdf/Application/FontService.php @@ -1,6 +1,6 @@ */ + private const PAGE_SETTINGS = [ + 'pdf_paper_width', + 'pdf_paper_height', + 'pdf_orientation', + 'pdf_margin_top', + 'pdf_margin_right', + 'pdf_margin_bottom', + 'pdf_margin_left', + ]; + + /** @var list */ + private const PAGE_BOOLEANS = [ + 'pdf_page_numbers', + ]; + + /** + * Apply the persisted instance-wide PDF settings to Laravel's runtime config. + */ + public function applyGlobalConfig(): void + { + $pageSettings = collect(self::PAGE_SETTINGS) + ->mapWithKeys(fn (string $setting): array => [$setting => self::configKeyFor($setting)]) + ->all(); + + $pdfSettings = Setting::getSettings(array_merge( + ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa'], + self::PAGE_BOOLEANS, + array_keys($pageSettings), + )); + + if (! empty($pdfSettings['pdf_driver'])) { + Config::set('pdf.driver', $pdfSettings['pdf_driver']); + + if ($pdfSettings['pdf_driver'] === 'gotenberg') { + if (! empty($pdfSettings['gotenberg_host'])) { + Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']); + } + + // An explicitly stored blank means an ordinary PDF and must + // override an environment-level PDF/A default. + if (isset($pdfSettings['gotenberg_pdfa'])) { + Config::set('pdf.connections.gotenberg.pdfa', $pdfSettings['gotenberg_pdfa'] ?: null); + } + } + } + + foreach ($pageSettings as $setting => $configKey) { + if (isset($pdfSettings[$setting]) && trim((string) $pdfSettings[$setting]) !== '') { + Config::set($configKey, $pdfSettings[$setting]); + } + } + + if (isset($pdfSettings['pdf_page_numbers'])) { + Config::set( + 'pdf.page.page_numbers', + filter_var($pdfSettings['pdf_page_numbers'], FILTER_VALIDATE_BOOLEAN) + ); + } + } + + /** + * @return array + */ + public function environment(): array + { + $pdfSettings = Setting::getSettings(array_merge( + ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa'], + self::PAGE_SETTINGS, + self::PAGE_BOOLEANS, + )); + + $environment = [ + 'pdf_driver' => $pdfSettings['pdf_driver'] ?? config('pdf.driver'), + 'gotenberg_host' => $pdfSettings['gotenberg_host'] ?? config('pdf.connections.gotenberg.host'), + 'gotenberg_pdfa' => $pdfSettings['gotenberg_pdfa'] ?? config('pdf.connections.gotenberg.pdfa') ?? '', + ]; + + foreach (self::PAGE_SETTINGS as $setting) { + $environment[$setting] = $pdfSettings[$setting] ?? config(self::configKeyFor($setting)); + } + + foreach (self::PAGE_BOOLEANS as $setting) { + $environment[$setting] = filter_var( + $pdfSettings[$setting] ?? config(self::configKeyFor($setting)), + FILTER_VALIDATE_BOOLEAN + ); + } + + return $environment; + } + + /** + * @param array $input + */ + public function store(array $input): void + { + $driver = $input['pdf_driver']; + $settings = ['pdf_driver' => $driver]; + + foreach (self::PAGE_SETTINGS as $setting) { + $settings[$setting] = $input[$setting] ?? null; + } + + // Page numbers are a Gotenberg-only control. When the dompdf form omits + // it, retain the operator's existing choice. + foreach (self::PAGE_BOOLEANS as $setting) { + if (array_key_exists($setting, $input)) { + $settings[$setting] = filter_var($input[$setting], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; + } + } + + if ($driver === 'gotenberg') { + $settings['gotenberg_host'] = $input['gotenberg_host']; + $settings['gotenberg_pdfa'] = $input['gotenberg_pdfa'] ?? ''; + } + + Setting::setSettings($settings); + } + + private static function configKeyFor(string $setting): string + { + return 'pdf.page.'.substr($setting, strlen('pdf_')); + } +} diff --git a/app/Traits/GeneratesPdfTrait.php b/app/Platform/Pdf/Concerns/GeneratesPdf.php similarity index 95% rename from app/Traits/GeneratesPdfTrait.php rename to app/Platform/Pdf/Concerns/GeneratesPdf.php index f6ce684d..f488d5e9 100644 --- a/app/Traits/GeneratesPdfTrait.php +++ b/app/Platform/Pdf/Concerns/GeneratesPdf.php @@ -1,17 +1,17 @@ $this->withDriver( $driver, - fn () => app(PaymentService::class)->getPdfData($payment)->output() + fn () => app(PaymentPdfDataProvider::class)->getPdfData($payment)->output() )]; } diff --git a/app/Console/Commands/CreateTemplateCommand.php b/app/Platform/Pdf/Console/CreateTemplateCommand.php similarity index 98% rename from app/Console/Commands/CreateTemplateCommand.php rename to app/Platform/Pdf/Console/CreateTemplateCommand.php index b1b1b4d3..0a49f347 100644 --- a/app/Console/Commands/CreateTemplateCommand.php +++ b/app/Platform/Pdf/Console/CreateTemplateCommand.php @@ -1,8 +1,8 @@ authorize('manage pdf config'); + + $drivers = [ + 'dompdf', + 'gotenberg', + ]; + + return response()->json($drivers); + } + + /** + * Return the PDF settings + * + * @throws AuthorizationException + */ + public function getEnvironment(): JsonResponse + { + $this->authorize('manage pdf config'); + + return response()->json($this->configuration->environment()); + } + + /** + * Saves the settings + * + * @throws AuthorizationException + */ + public function saveEnvironment(PdfConfigurationRequest $request): JsonResponse + { + $this->authorize('manage pdf config'); + + $this->configuration->store($request->validated()); + + return response()->json([ + 'success' => 'pdf_variables_save_successfully', + ]); + } +} diff --git a/app/Http/Middleware/PdfMiddleware.php b/app/Platform/Pdf/Http/Middleware/PdfMiddleware.php similarity index 93% rename from app/Http/Middleware/PdfMiddleware.php rename to app/Platform/Pdf/Http/Middleware/PdfMiddleware.php index c912c566..e93df6c8 100644 --- a/app/Http/Middleware/PdfMiddleware.php +++ b/app/Platform/Pdf/Http/Middleware/PdfMiddleware.php @@ -1,6 +1,6 @@ app->bind('pdf.driver', PdfService::class); + $this->app->singleton(PdfConfigurationService::class); + $this->app->bind( + PdfConfigurator::class, + fn (Application $app): PdfConfigurationService => $app->make(PdfConfigurationService::class), + ); + } + + public function boot(): void + { + Gate::define('manage pdf config', [PdfAccessPolicy::class, 'manageConfiguration']); + View::addNamespace('pdf_templates', storage_path('app/templates/pdf')); + + $this->commands([ + ComparePdfDriversCommand::class, + CreateTemplateCommand::class, + ]); + } +} diff --git a/app/Platform/Pdf/Policies/PdfAccessPolicy.php b/app/Platform/Pdf/Policies/PdfAccessPolicy.php new file mode 100644 index 00000000..de4fff0d --- /dev/null +++ b/app/Platform/Pdf/Policies/PdfAccessPolicy.php @@ -0,0 +1,13 @@ +isSuperAdmin(); + } +} diff --git a/app/Support/Pdf/DompdfDriver.php b/app/Platform/Pdf/Rendering/DompdfDriver.php similarity index 98% rename from app/Support/Pdf/DompdfDriver.php rename to app/Platform/Pdf/Rendering/DompdfDriver.php index 3c58ca99..69921581 100644 --- a/app/Support/Pdf/DompdfDriver.php +++ b/app/Platform/Pdf/Rendering/DompdfDriver.php @@ -1,6 +1,6 @@ */ - public static function forDocument(string $subject, ?string $number, ?Company $company): array + public static function forDocument(string $subject, ?string $number, ?object $company): array { $title = trim($subject.' '.($number ?? '')); diff --git a/app/Support/Pdf/PdfPageSetup.php b/app/Platform/Pdf/Rendering/PdfPageSetup.php similarity index 99% rename from app/Support/Pdf/PdfPageSetup.php rename to app/Platform/Pdf/Rendering/PdfPageSetup.php index 1cc15ec4..42112044 100644 --- a/app/Support/Pdf/PdfPageSetup.php +++ b/app/Platform/Pdf/Rendering/PdfPageSetup.php @@ -1,6 +1,6 @@ > + */ + public static function aliases(): array + { + return [ + 'address' => Address::class, + 'ai_conversation' => AiConversation::class, + 'ai_message' => AiMessage::class, + 'company' => Company::class, + 'company_invitation' => CompanyInvitation::class, + 'company_setting' => CompanySetting::class, + 'country' => Country::class, + 'currency' => Currency::class, + 'custom_field' => CustomField::class, + 'custom_field_value' => CustomFieldValue::class, + 'customer' => Customer::class, + 'email_log' => EmailLog::class, + self::ESTIMATE_ALIAS => Estimate::class, + 'estimate_item' => EstimateItem::class, + 'exchange_rate_log' => ExchangeRateLog::class, + 'exchange_rate_provider' => ExchangeRateProvider::class, + 'expense' => Expense::class, + 'expense_category' => ExpenseCategory::class, + 'file_disk' => FileDisk::class, + 'impersonation_log' => ImpersonationLog::class, + self::INVOICE_ALIAS => Invoice::class, + 'invoice_item' => InvoiceItem::class, + 'item' => Item::class, + 'marketplace_credential' => MarketplaceCredential::class, + 'marketplace_operation' => MarketplaceOperation::class, + 'module' => Module::class, + 'note' => Note::class, + self::PAYMENT_ALIAS => Payment::class, + 'payment_allocation' => PaymentAllocation::class, + 'payment_method' => PaymentMethod::class, + 'recurring_invoice' => RecurringInvoice::class, + 'setting' => Setting::class, + 'tax' => Tax::class, + 'tax_type' => TaxType::class, + 'transaction' => Transaction::class, + 'unit' => Unit::class, + 'user' => User::class, + 'user_setting' => UserSetting::class, + 'bouncer_ability' => Ability::class, + 'bouncer_role' => Role::class, + ]; + } + + public static function enforce(): void + { + Relation::enforceMorphMap(self::aliases()); + } + + /** + * @param class-string $model + */ + public static function aliasFor(string $model): string + { + $alias = array_search($model, self::aliases(), true); + + if (! is_string($alias)) { + throw new InvalidArgumentException("Model [{$model}] has no stable database identity."); + } + + return $alias; + } + + /** + * Preserve the model discriminator exposed by the existing v1 API. + */ + public static function publicType(string $databaseType): string + { + $model = self::aliases()[$databaseType] ?? null; + + if ($model === null || str_starts_with($databaseType, 'bouncer_')) { + return $databaseType; + } + + return 'App\\Models\\'.class_basename($model); + } +} diff --git a/app/Services/Storage/BackupConfigurationFactory.php b/app/Platform/Storage/Application/BackupConfigurationFactory.php similarity index 86% rename from app/Services/Storage/BackupConfigurationFactory.php rename to app/Platform/Storage/Application/BackupConfigurationFactory.php index e21cc41c..7eb0e020 100644 --- a/app/Services/Storage/BackupConfigurationFactory.php +++ b/app/Platform/Storage/Application/BackupConfigurationFactory.php @@ -1,8 +1,8 @@ first(); + + if (! $fileDisk) { + return; + } + + $diskName = $this->registerDisk($fileDisk); + + config(['media-library.disk_name' => $diskName]); + } + public function create(Request $request): FileDisk { if ($request->set_as_default) { diff --git a/app/Console/Commands/MigrateMediaToPrivateDisk.php b/app/Platform/Storage/Console/MigrateMediaToPrivateDisk.php similarity index 95% rename from app/Console/Commands/MigrateMediaToPrivateDisk.php rename to app/Platform/Storage/Console/MigrateMediaToPrivateDisk.php index 573a48aa..6dbe4667 100644 --- a/app/Console/Commands/MigrateMediaToPrivateDisk.php +++ b/app/Platform/Storage/Console/MigrateMediaToPrivateDisk.php @@ -1,10 +1,10 @@ has('file_disk_id')) { $file_disk = FileDisk::find($request->file_disk_id); @@ -26,7 +26,7 @@ class ConfigMiddleware $file_disk->setConfig(); } } - // Default file disk is now handled by AppConfigProvider during boot + // The default file disk is applied during application bootstrap. } return $next($request); diff --git a/app/Http/Requests/DiskEnvironmentRequest.php b/app/Platform/Storage/Http/Requests/DiskEnvironmentRequest.php similarity index 98% rename from app/Http/Requests/DiskEnvironmentRequest.php rename to app/Platform/Storage/Http/Requests/DiskEnvironmentRequest.php index e2ec555b..f6a916b3 100644 --- a/app/Http/Requests/DiskEnvironmentRequest.php +++ b/app/Platform/Storage/Http/Requests/DiskEnvironmentRequest.php @@ -1,6 +1,6 @@ isSuperAdmin(); + } +} diff --git a/app/Rules/Backup/BackupDisk.php b/app/Platform/Storage/Rules/BackupDisk.php similarity index 94% rename from app/Rules/Backup/BackupDisk.php rename to app/Platform/Storage/Rules/BackupDisk.php index 739452fa..3d95c480 100644 --- a/app/Rules/Backup/BackupDisk.php +++ b/app/Platform/Storage/Rules/BackupDisk.php @@ -1,6 +1,6 @@ app->singleton(FileDiskService::class); + $this->app->bind( + StorageConfigurator::class, + fn (Application $app): FileDiskService => $app->make(FileDiskService::class), + ); + } + + /** + * Bootstrap services. + */ + public function boot(): void + { + Gate::define('manage backups', [StorageAccessPolicy::class, 'manage']); + Gate::define('manage file disk', [StorageAccessPolicy::class, 'manage']); + + $this->commands([ + MigrateMediaToPrivateDisk::class, + ]); + + Storage::extend('dropbox', function ($app, $config) { + $client = new DropboxClient( + $config['token'] + ); + + $root = trim($config['root'] ?? '', '/'); + $adapter = new DropboxAdapter($client, $root); + $flysystem = new Filesystem($adapter); + + return new FilesystemAdapter($flysystem, $adapter, $config); + }); + } +} diff --git a/app/Platform/Storage/routes/company.php b/app/Platform/Storage/routes/company.php new file mode 100644 index 00000000..31498bfb --- /dev/null +++ b/app/Platform/Storage/routes/company.php @@ -0,0 +1,14 @@ +isSuperAdmin(); - } -} diff --git a/app/Policies/SettingsPolicy.php b/app/Policies/SettingsPolicy.php deleted file mode 100644 index 6dce39e7..00000000 --- a/app/Policies/SettingsPolicy.php +++ /dev/null @@ -1,84 +0,0 @@ -id == $company->owner_id) { - return true; - } - - return false; - } - - public function manageBackups(User $user) - { - if ($user->isSuperAdmin()) { - return true; - } - - return false; - } - - public function manageFileDisk(User $user) - { - if ($user->isSuperAdmin()) { - return true; - } - - return false; - } - - public function manageEmailConfig(User $user) - { - if ($user->isSuperAdmin()) { - return true; - } - - return false; - } - - public function manageAiConfig(User $user) - { - if ($user->isSuperAdmin()) { - return true; - } - - return false; - } - - /** - * Any authenticated user with an active company context can use AI features, - * subject to the per-company kill-switch in AiConfigurationService. - */ - public function useAi(User $user) - { - return $user !== null; - } - - public function managePDFConfig(User $user) - { - if ($user->isSuperAdmin()) { - return true; - } - - return false; - } - - public function manageSettings(User $user) - { - if ($user->isSuperAdmin()) { - return true; - } - - return false; - } -} diff --git a/app/Providers/AiServiceProvider.php b/app/Providers/AiServiceProvider.php deleted file mode 100644 index 57180a52..00000000 --- a/app/Providers/AiServiceProvider.php +++ /dev/null @@ -1,56 +0,0 @@ -app->singleton(AiToolRegistry::class, function (Application $app): AiToolRegistry { - $registry = new AiToolRegistry; - - // Built-in read-only tools (order is presentation-only; the LLM picks). - $registry->register(new SearchInvoicesTool); - $registry->register(new GetInvoiceTool); - $registry->register(new ListOverdueInvoicesTool); - $registry->register(new SearchCustomersTool); - $registry->register(new GetCustomerTool); - $registry->register(new ListRecentPaymentsTool); - $registry->register(new SearchItemsTool); - $registry->register(new ListExpenseCategoriesTool); - $registry->register(new GetCompanyStatsTool); - - // Ranking tools — group-by aggregates the individual-record - // tools above can't express. - $registry->register(new RankTopCustomersTool); - $registry->register(new RankTopItemsTool); - $registry->register(new RankExpenseCategoriesTool); - - return $registry; - }); - } -} diff --git a/app/Providers/AppConfigProvider.php b/app/Providers/AppConfigProvider.php deleted file mode 100644 index a04576c9..00000000 --- a/app/Providers/AppConfigProvider.php +++ /dev/null @@ -1,123 +0,0 @@ -configureMailFromDatabase(); - $this->configurePDFFromDatabase(); - $this->configureFileSystemFromDatabase(); - } - - /** - * Configure mail settings from database - */ - protected function configureMailFromDatabase(): void - { - try { - app(MailConfigurationService::class)->applyGlobalConfig(); - } catch (\Exception $e) { - // Silently fail if database is not available (during installation, migrations, etc.) - // This prevents the application from breaking during setup - } - } - - /** - * Configure PDF settings from database - */ - protected function configurePDFFromDatabase(): void - { - try { - $pageSettings = [ - 'pdf_paper_width' => 'pdf.page.paper_width', - 'pdf_paper_height' => 'pdf.page.paper_height', - 'pdf_orientation' => 'pdf.page.orientation', - 'pdf_margin_top' => 'pdf.page.margin_top', - 'pdf_margin_right' => 'pdf.page.margin_right', - 'pdf_margin_bottom' => 'pdf.page.margin_bottom', - 'pdf_margin_left' => 'pdf.page.margin_left', - ]; - - $pdfSettings = Setting::getSettings(array_merge( - ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa', 'pdf_page_numbers'], - array_keys($pageSettings), - )); - - if (! empty($pdfSettings['pdf_driver'])) { - Config::set('pdf.driver', $pdfSettings['pdf_driver']); - - if ($pdfSettings['pdf_driver'] === 'gotenberg') { - if (! empty($pdfSettings['gotenberg_host'])) { - Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']); - } - - // Empty is a real choice here -- it means an ordinary PDF -- - // so an explicitly stored blank must override an env default. - if (isset($pdfSettings['gotenberg_pdfa'])) { - Config::set('pdf.connections.gotenberg.pdfa', $pdfSettings['gotenberg_pdfa'] ?: null); - } - } - } - - // Page geometry is applied regardless of driver. Note the isset guard - // rather than !empty: a saved margin of "0mm" is a deliberate choice, - // and !empty() would discard it and silently fall back to the default. - foreach ($pageSettings as $setting => $configKey) { - if (isset($pdfSettings[$setting]) && trim((string) $pdfSettings[$setting]) !== '') { - Config::set($configKey, $pdfSettings[$setting]); - } - } - - // Stored as a string, so cast rather than passing '0' through as a - // truthy value. - if (isset($pdfSettings['pdf_page_numbers'])) { - Config::set( - 'pdf.page.page_numbers', - filter_var($pdfSettings['pdf_page_numbers'], FILTER_VALIDATE_BOOLEAN) - ); - } - } catch (\Exception $e) { - // Silently fail if database is not available (during installation, migrations, etc.) - // This prevents the application from breaking during setup - } - } - - /** - * Configure file system settings from database - */ - protected function configureFileSystemFromDatabase(): void - { - try { - $fileDisk = FileDisk::whereSetAsDefault(true)->first(); - - if (! $fileDisk) { - return; - } - - $diskName = app(FileDiskService::class)->registerDisk($fileDisk); - - // Point Spatie Media Library at the resolved disk - config(['media-library.disk_name' => $diskName]); - } catch (\Exception $e) { - // Silently fail if database is not available (during installation, migrations, etc.) - } - } -} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1fe77d44..d5c99f5d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,39 +2,15 @@ namespace App\Providers; -use App\Models\AiConversation; -use App\Models\User; -use App\Policies\AiConversationPolicy; -use App\Policies\CompanyPolicy; -use App\Policies\CreditNotePolicy; -use App\Policies\CustomerPolicy; -use App\Policies\DashboardPolicy; -use App\Policies\EstimatePolicy; -use App\Policies\ExpensePolicy; -use App\Policies\InvoicePolicy; -use App\Policies\ItemPolicy; -use App\Policies\ModulesPolicy; -use App\Policies\NotePolicy; -use App\Policies\OwnerPolicy; -use App\Policies\PaymentPolicy; -use App\Policies\RecurringInvoicePolicy; -use App\Policies\ReportPolicy; -use App\Policies\RolePolicy; -use App\Policies\SettingsPolicy; -use App\Policies\UserPolicy; +use App\Platform\Operations\Installation\Application\InstallationState; +use App\Platform\Persistence\ModelIdentityMap; use App\Support\Bouncer\BouncerDefaultScope; -use App\Support\Setup\InstallUtils; -use App\Support\Setup\InstallWizardAuth; -use Gate; -use Illuminate\Http\Request; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Illuminate\Support\ServiceProvider; -use Laravel\Sanctum\Sanctum; use Silber\Bouncer\Database\Models as BouncerModels; -use Silber\Bouncer\Database\Role; -use View; class AppServiceProvider extends ServiceProvider { @@ -61,19 +37,15 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - $this->configureInstallWizardTokenAuth(); + ModelIdentityMap::enforce(); + Factory::guessFactoryNamesUsing( + fn (string $modelName): string => 'Database\\Factories\\'.class_basename($modelName).'Factory' + ); - if (InstallUtils::isDbCreated()) { + if (InstallationState::isDbCreated()) { $this->addMenus(); } - Gate::policy(Role::class, RolePolicy::class); - Gate::policy(AiConversation::class, AiConversationPolicy::class); - Gate::define('manage module settings', fn (User $user): bool => $user->isSuperAdmin() || $user->isOwner()); - - View::addNamespace('pdf_templates', storage_path('app/templates/pdf')); - - $this->bootAuth(); $this->bootBroadcast(); // In demo mode, prevent all outgoing emails and notifications @@ -135,66 +107,8 @@ class AppServiceProvider extends ServiceProvider ->data('priority', $data['priority'] ?? 100); } - public function bootAuth() - { - - Gate::define('create company', [CompanyPolicy::class, 'create']); - Gate::define('transfer company ownership', [CompanyPolicy::class, 'transferOwnership']); - Gate::define('delete company', [CompanyPolicy::class, 'delete']); - - Gate::define('manage modules', [ModulesPolicy::class, 'manageModules']); - - Gate::define('manage settings', [SettingsPolicy::class, 'manageSettings']); - Gate::define('manage company', [SettingsPolicy::class, 'manageCompany']); - Gate::define('manage backups', [SettingsPolicy::class, 'manageBackups']); - Gate::define('manage file disk', [SettingsPolicy::class, 'manageFileDisk']); - Gate::define('manage email config', [SettingsPolicy::class, 'manageEmailConfig']); - Gate::define('manage ai config', [SettingsPolicy::class, 'manageAiConfig']); - Gate::define('use ai', [SettingsPolicy::class, 'useAi']); - Gate::define('manage pdf config', [SettingsPolicy::class, 'managePDFConfig']); - Gate::define('manage notes', [NotePolicy::class, 'manageNotes']); - Gate::define('view notes', [NotePolicy::class, 'viewNotes']); - - Gate::define('send invoice', [InvoicePolicy::class, 'send']); - Gate::define('create credit note', [CreditNotePolicy::class, 'create']); - Gate::define('send estimate', [EstimatePolicy::class, 'send']); - Gate::define('send payment', [PaymentPolicy::class, 'send']); - - Gate::define('delete multiple items', [ItemPolicy::class, 'deleteMultiple']); - Gate::define('delete multiple customers', [CustomerPolicy::class, 'deleteMultiple']); - Gate::define('delete multiple users', [UserPolicy::class, 'deleteMultiple']); - Gate::define('delete multiple invoices', [InvoicePolicy::class, 'deleteMultiple']); - Gate::define('delete multiple estimates', [EstimatePolicy::class, 'deleteMultiple']); - Gate::define('delete multiple expenses', [ExpensePolicy::class, 'deleteMultiple']); - Gate::define('delete multiple payments', [PaymentPolicy::class, 'deleteMultiple']); - Gate::define('delete multiple recurring invoices', [RecurringInvoicePolicy::class, 'deleteMultiple']); - - Gate::define('view dashboard', [DashboardPolicy::class, 'view']); - - Gate::define('view report', [ReportPolicy::class, 'viewReport']); - - Gate::define('owner only', [OwnerPolicy::class, 'managedByOwner']); - } - public function bootBroadcast() { Broadcast::routes(['middleware' => 'api.auth']); } - - private function configureInstallWizardTokenAuth(): void - { - Sanctum::authenticateAccessTokensUsing(function ($accessToken, bool $isValid): bool { - if (! $isValid) { - return false; - } - - $request = request(); - - if (! $request instanceof Request || ! $request->attributes->get('install_wizard', false)) { - return $isValid; - } - - return $accessToken->can(InstallWizardAuth::TOKEN_ABILITY); - }); - } } diff --git a/app/Providers/DropboxServiceProvider.php b/app/Providers/DropboxServiceProvider.php deleted file mode 100644 index 0e0b48c5..00000000 --- a/app/Providers/DropboxServiceProvider.php +++ /dev/null @@ -1,39 +0,0 @@ - PdfService::class, - ]; -} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 9e3d4a9a..04a4f5eb 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -54,14 +54,5 @@ class RouteServiceProvider extends ServiceProvider return Limit::perMinute(60); }); - // AI endpoints — per user per company, fairly generous for chat use - // but low enough to contain runaway clients / loops. - RateLimiter::for('ai', function (Request $request) { - $user = $request->user(); - $companyId = $request->header('company') ?? 'noop'; - $key = $user ? "{$user->id}:{$companyId}" : $request->ip(); - - return Limit::perMinute(30)->by($key); - }); } } diff --git a/app/Rules/CreditNoteDeletedTogether.php b/app/Rules/CreditNoteDeletedTogether.php index 84d0c998..ac226a5c 100644 --- a/app/Rules/CreditNoteDeletedTogether.php +++ b/app/Rules/CreditNoteDeletedTogether.php @@ -2,7 +2,7 @@ namespace App\Rules; -use App\Models\Invoice; +use App\Domains\Sales\Models\Invoice; use Closure; use Illuminate\Contracts\Validation\ValidationRule; diff --git a/app/Services/Company/MemberService.php b/app/Services/Company/MemberService.php deleted file mode 100644 index 1089194a..00000000 --- a/app/Services/Company/MemberService.php +++ /dev/null @@ -1,89 +0,0 @@ -getUserPayload()); - - $user->setSettings([ - 'language' => 'default', - ]); - - $companies = collect($request->companies); - $user->companies()->sync($companies->pluck('id')); - - foreach ($companies as $company) { - BouncerFacade::scope()->to($company['id']); - - BouncerFacade::sync($user)->roles([$company['role']]); - } - - return $user; - } - - public function update(User $user, MemberRequest $request): User - { - $user->update($request->getUserPayload()); - - $companies = collect($request->companies); - $user->companies()->sync($companies->pluck('id')); - - foreach ($companies as $company) { - BouncerFacade::scope()->to($company['id']); - - BouncerFacade::sync($user)->roles([$company['role']]); - } - - return $user; - } - - public function delete(array $ids): bool - { - foreach ($ids as $id) { - $user = User::find($id); - - if ($user->invoices()->exists()) { - $user->invoices()->update(['creator_id' => null]); - } - - if ($user->estimates()->exists()) { - $user->estimates()->update(['creator_id' => null]); - } - - if ($user->customers()->exists()) { - $user->customers()->update(['creator_id' => null]); - } - - if ($user->recurringInvoices()->exists()) { - $user->recurringInvoices()->update(['creator_id' => null]); - } - - if ($user->expenses()->exists()) { - $user->expenses()->update(['creator_id' => null]); - } - - if ($user->payments()->exists()) { - $user->payments()->update(['creator_id' => null]); - } - - if ($user->items()->exists()) { - $user->items()->update(['creator_id' => null]); - } - - if ($user->settings()->exists()) { - $user->settings()->delete(); - } - - $user->delete(); - } - - return true; - } -} diff --git a/app/Services/CustomFieldService.php b/app/Services/CustomFieldService.php deleted file mode 100644 index 5a6cab83..00000000 --- a/app/Services/CustomFieldService.php +++ /dev/null @@ -1,28 +0,0 @@ -validated(); - $data[getCustomFieldValueKey($request->type)] = $request->default_answer; - $data['company_id'] = $request->header('company'); - $data['slug'] = clean_slug($request->model_type, $request->name); - - return CustomField::create($data); - } - - public function update(CustomField $customField, Request $request): CustomField - { - $data = $request->validated(); - $data[getCustomFieldValueKey($request->type)] = $request->default_answer; - $customField->update($data); - - return $customField; - } -} diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php deleted file mode 100644 index c36366d6..00000000 --- a/app/Services/CustomerService.php +++ /dev/null @@ -1,245 +0,0 @@ -getCustomerPayload()); - - if ($request->shipping) { - if ($request->hasAddress($request->shipping)) { - $customer->addresses()->create($request->getShippingAddress()); - } - } - - if ($request->billing) { - if ($request->hasAddress($request->billing)) { - $customer->addresses()->create($request->getBillingAddress()); - } - } - - $customFields = $request->customFields; - - if ($customFields) { - $customer->addCustomFields($customFields); - } - - return Customer::with('billingAddress', 'shippingAddress', 'fields')->find($customer->id); - } - - /** - * @throws ValidationException - */ - public function update(Request $request, Customer $customer): Customer - { - $condition = $customer->estimates()->exists() || $customer->invoices()->exists() || $customer->payments()->exists() || $customer->recurringInvoices()->exists(); - - if (($customer->currency_id !== $request->currency_id) && $condition) { - throw ValidationException::withMessages([ - 'currency_id' => ['you_cannot_edit_currency'], - ]); - } - - $customer->update($request->getCustomerPayload()); - - $customer->addresses()->delete(); - - if ($request->shipping) { - if ($request->hasAddress($request->shipping)) { - $customer->addresses()->create($request->getShippingAddress()); - } - } - - if ($request->billing) { - if ($request->hasAddress($request->billing)) { - $customer->addresses()->create($request->getBillingAddress()); - } - } - - $customFields = $request->customFields; - - if ($customFields) { - $customer->updateCustomFields($customFields); - } - - return Customer::with('billingAddress', 'shippingAddress', 'fields')->find($customer->id); - } - - public function delete(Collection $ids): bool - { - foreach ($ids as $id) { - $customer = Customer::find($id); - - if ($customer->estimates()->exists()) { - $customer->estimates()->delete(); - } - - // There are no database foreign keys for payment allocations. Clear - // them before the bulk payment delete, then remove invoices so an - // allocation cannot outlive either side of the relationship. - if ($customer->payments()->exists()) { - PaymentAllocation::query() - ->whereIn('payment_id', $customer->payments()->select('id')) - ->delete(); - $customer->payments()->delete(); - } - - if ($customer->invoices()->exists()) { - $customer->invoices->map(function ($invoice) { - if ($invoice->transactions()->exists()) { - $invoice->transactions()->delete(); - } - $invoice->delete(); - }); - } - - if ($customer->addresses()->exists()) { - $customer->addresses()->delete(); - } - - if ($customer->expenses()->exists()) { - $customer->expenses->each->delete(); - } - - if ($customer->recurringInvoices()->exists()) { - foreach ($customer->recurringInvoices as $recurringInvoice) { - if ($recurringInvoice->items()->exists()) { - $recurringInvoice->items()->delete(); - } - - $recurringInvoice->delete(); - } - } - - $customer->delete(); - } - - return true; - } - - public function getStats(Customer $customer, int $companyId, bool $previousYear = false): array - { - $i = 0; - $months = []; - $invoiceTotals = []; - $expenseTotals = []; - $receiptTotals = []; - $netProfits = []; - $monthCounter = 0; - $fiscalYear = CompanySetting::getSetting('fiscal_year', $companyId); - $startDate = Carbon::now(); - $start = Carbon::now(); - $end = Carbon::now(); - $terms = explode('-', $fiscalYear); - $companyStartMonth = intval($terms[0]); - - if ($companyStartMonth <= $start->month) { - $startDate->month($companyStartMonth)->startOfMonth(); - $start->month($companyStartMonth)->startOfMonth(); - $end->month($companyStartMonth)->endOfMonth(); - } else { - $startDate->subYear()->month($companyStartMonth)->startOfMonth(); - $start->subYear()->month($companyStartMonth)->startOfMonth(); - $end->subYear()->month($companyStartMonth)->endOfMonth(); - } - - if ($previousYear) { - $startDate->subYear()->startOfMonth(); - $start->subYear()->startOfMonth(); - $end->subYear()->endOfMonth(); - } - - while ($monthCounter < 12) { - array_push( - $invoiceTotals, - Invoice::whereBetween( - 'invoice_date', - [$start->format('Y-m-d'), $end->format('Y-m-d')] - ) - ->whereCompany() - ->whereCustomer($customer->id) - ->sum('base_total') ?? 0 - ); - array_push( - $expenseTotals, - Expense::whereBetween( - 'expense_date', - [$start->format('Y-m-d'), $end->format('Y-m-d')] - ) - ->whereCompany() - ->whereUser($customer->id) - ->sum('base_amount') ?? 0 - ); - array_push( - $receiptTotals, - Payment::whereBetween( - 'payment_date', - [$start->format('Y-m-d'), $end->format('Y-m-d')] - ) - ->whereCompany() - ->whereCustomer($customer->id) - ->sum('base_amount') ?? 0 - ); - array_push( - $netProfits, - ($receiptTotals[$i] - $expenseTotals[$i]) - ); - $i++; - array_push($months, $start->translatedFormat('M')); - $monthCounter++; - $end->startOfMonth(); - $start->addMonth()->startOfMonth(); - $end->addMonth()->endOfMonth(); - } - - $start->subMonth()->endOfMonth(); - - $salesTotal = Invoice::whereBetween( - 'invoice_date', - [$startDate->format('Y-m-d'), $start->format('Y-m-d')] - ) - ->whereCompany() - ->whereCustomer($customer->id) - ->sum('base_total'); - $totalReceipts = Payment::whereBetween( - 'payment_date', - [$startDate->format('Y-m-d'), $start->format('Y-m-d')] - ) - ->whereCompany() - ->whereCustomer($customer->id) - ->sum('base_amount'); - $totalExpenses = Expense::whereBetween( - 'expense_date', - [$startDate->format('Y-m-d'), $start->format('Y-m-d')] - ) - ->whereCompany() - ->whereUser($customer->id) - ->sum('base_amount'); - $netProfit = (int) $totalReceipts - (int) $totalExpenses; - - return [ - 'months' => $months, - 'invoiceTotals' => $invoiceTotals, - 'expenseTotals' => $expenseTotals, - 'receiptTotals' => $receiptTotals, - 'netProfit' => $netProfit, - 'netProfits' => $netProfits, - 'salesTotal' => $salesTotal, - 'totalReceipts' => $totalReceipts, - 'totalExpenses' => $totalExpenses, - ]; - } -} diff --git a/app/Services/Document/ExpenseService.php b/app/Services/Document/ExpenseService.php deleted file mode 100644 index bb77f967..00000000 --- a/app/Services/Document/ExpenseService.php +++ /dev/null @@ -1,113 +0,0 @@ -getExpensePayload()); - - if ($request->has('taxes')) { - $this->syncTaxes($expense, $request->input('taxes')); - } - - return $expense; - }); - - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); - - if ((string) $expense['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($expense); - } - - if ($request->hasFile('attachment_receipt')) { - $expense->addMediaFromRequest('attachment_receipt')->toMediaCollection('receipts'); - } - - if ($request->customFields) { - $expense->addCustomFields(json_decode($request->customFields)); - } - - return $expense->load('taxes.taxType'); - } - - public function update(Expense $expense, Request $request): Expense - { - $data = $request->getExpensePayload(); - - DB::transaction(function () use ($expense, $data, $request): void { - $expense->update($data); - - if ($request->has('taxes')) { - $this->syncTaxes($expense, $request->input('taxes')); - } - }); - - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); - - if ((string) $data['currency_id'] !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($expense); - } - - if (isset($request->is_attachment_receipt_removed) && (bool) $request->is_attachment_receipt_removed) { - $expense->clearMediaCollection('receipts'); - } - if ($request->hasFile('attachment_receipt')) { - $expense->clearMediaCollection('receipts'); - $expense->addMediaFromRequest('attachment_receipt')->toMediaCollection('receipts'); - } - - if ($request->customFields) { - $expense->updateCustomFields(json_decode($request->customFields)); - } - - return $expense->fresh('taxes.taxType'); - } - - /** - * Replace an expense's receipt tax snapshots with the submitted tax amounts. - */ - private function syncTaxes(Expense $expense, array $taxes): void - { - $expense->taxes()->delete(); - - if ($taxes === []) { - return; - } - - $taxTypes = TaxType::query() - ->where('company_id', $expense->company_id) - ->where('type', TaxType::TYPE_GENERAL) - ->whereTransactionType(TaxType::TRANSACTION_TYPE_PURCHASES) - ->whereIn('id', collect($taxes)->pluck('tax_type_id')) - ->get() - ->keyBy('id'); - - foreach ($taxes as $tax) { - $taxType = $taxTypes->get($tax['tax_type_id']); - - $expense->taxes()->create([ - 'tax_type_id' => $taxType->id, - 'company_id' => $expense->company_id, - 'currency_id' => $expense->currency_id, - 'exchange_rate' => $expense->exchange_rate, - 'amount' => (int) $tax['amount'], - 'base_amount' => (int) round($tax['amount'] * $expense->exchange_rate), - 'name' => $taxType->name, - 'percent' => $taxType->percent, - 'fixed_amount' => $taxType->fixed_amount, - 'calculation_type' => $taxType->calculation_type, - 'compound_tax' => $taxType->compound_tax, - ]); - } - } -} diff --git a/app/Services/Document/PaymentService.php b/app/Services/Document/PaymentService.php deleted file mode 100644 index 6feff4e8..00000000 --- a/app/Services/Document/PaymentService.php +++ /dev/null @@ -1,244 +0,0 @@ -getPaymentPayload(); - $allocations = $request->validated('allocations') ?? []; - - $payment = DB::transaction(function () use ($data, $allocations, $request): Payment { - $payment = Payment::create($data); - $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); - - $serial = (new SerialNumberService) - ->setModel($payment) - ->setCompany($payment->company_id) - ->setCustomer($payment->customer_id) - ->setNextNumbers(); - - $payment->sequence_number = $serial->nextSequenceNumber; - $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $payment->save(); - - $this->paymentAllocationService->replace($payment, $allocations); - - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); - - if ((string) $payment->currency_id !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($payment); - } - - if ($request->customFields) { - $payment->addCustomFields($request->customFields); - } - - return $payment; - }); - - return $this->loadPayment($payment); - } - - public function update(Payment $payment, Request $request): Payment - { - $data = $request->getPaymentPayload(); - $replaceAllocations = $request->exists('allocations'); - $requestedAllocations = $request->validated('allocations') ?? []; - - $payment = DB::transaction(function () use ($payment, $data, $replaceAllocations, $requestedAllocations, $request): Payment { - $lockedPayment = Payment::query()->whereKey($payment->id)->lockForUpdate()->firstOrFail(); - $allocations = $replaceAllocations - ? $requestedAllocations - : $lockedPayment->allocations() - ->get(['invoice_id', 'amount']) - ->map(fn ($allocation) => [ - 'invoice_id' => (int) $allocation->invoice_id, - 'amount' => (int) $allocation->amount, - ]) - ->all(); - $customerChanged = (int) $lockedPayment->customer_id !== (int) $data['customer_id']; - - if ($customerChanged && $allocations !== []) { - throw ValidationException::withMessages([ - 'customer_id' => ['payment_customer_change_requires_unallocated_credit'], - ]); - } - - $serial = (new SerialNumberService) - ->setModel($lockedPayment) - ->setCompany($lockedPayment->company_id) - ->setCustomer($data['customer_id']) - ->setModelObject($lockedPayment->id) - ->setNextNumbers(); - - $data['customer_sequence_number'] = $serial->nextCustomerSequenceNumber; - $lockedPayment->update($data); - $this->paymentAllocationService->replace($lockedPayment, $allocations); - - $companyCurrency = CompanySetting::getSetting('currency', $request->header('company')); - - if ((string) $lockedPayment->currency_id !== $companyCurrency) { - ExchangeRateLog::addExchangeRateLog($lockedPayment); - } - - if ($request->customFields) { - $lockedPayment->updateCustomFields($request->customFields); - } - - return $lockedPayment; - }); - - return $this->loadPayment($payment); - } - - public function delete(Collection $ids): bool - { - DB::transaction(function () use ($ids): void { - foreach ($ids->sort() as $id) { - $payment = Payment::query()->whereKey($id)->lockForUpdate()->first(); - - if (! $payment) { - continue; - } - - $this->paymentAllocationService->replace($payment, []); - $payment->delete(); - } - }); - - return true; - } - - public function sendPaymentData(Payment $payment, array $data): array - { - $data['payment'] = $payment->toArray(); - $data['user'] = $payment->customer->toArray(); - $data['company'] = Company::find($payment->company_id); - $data['body'] = $payment->getEmailBody($data['body']); - $data['attach']['data'] = ($payment->getEmailAttachmentSetting()) ? $this->getPdfData($payment) : null; - - return $data; - } - - public function send(Payment $payment, array $data): array - { - $data = $this->sendPaymentData($payment, $data); - - CompanyMailConfigService::apply($payment->company_id); - - $mail = \Mail::to($data['to']); - if (! empty($data['cc'])) { - $mail->cc($data['cc']); - } - if (! empty($data['bcc'])) { - $mail->bcc($data['bcc']); - } - $mail->send(new SendPaymentMail($data)); - - return [ - 'success' => true, - ]; - } - - public function getPdfData(Payment $payment) - { - $payment->loadMissing('allocations.invoice.currency'); - - $company = Company::find($payment->company_id); - $locale = CompanySetting::getSetting('language', $company->id); - - \App::setLocale($locale); - - $logo = $company->logo_path; - - view()->share([ - 'payment' => $payment, - 'company_address' => $payment->getCompanyAddress(), - 'billing_address' => $payment->getCustomerBillingAddress(), - 'notes' => $payment->getNotes(), - 'logo' => $logo ?? null, - ]); - - $templatePath = PdfTemplateUtils::resolveView('payment', 'payment'); - - if (request()->has('preview')) { - return view($templatePath); - } - - return Pdf::loadView($templatePath, PdfMetadata::forDocument( - __('pdf_payment_label'), - $payment->payment_number, - $company, - )); - } - - public function generateFromTransaction($transaction): Payment - { - $invoice = Invoice::find($transaction->invoice_id); - - $serial = (new SerialNumberService) - ->setModel(new Payment) - ->setCompany($invoice->company_id) - ->setCustomer($invoice->customer_id) - ->setNextNumbers(); - - $data['payment_number'] = $serial->getNextNumber(); - $data['payment_date'] = Carbon::now(); - $data['amount'] = $invoice->due_amount; - $data['payment_method_id'] = request()->payment_method_id; - $data['customer_id'] = $invoice->customer_id; - $data['exchange_rate'] = $invoice->exchange_rate; - $data['base_amount'] = (int) round($data['amount'] * $data['exchange_rate']); - $data['currency_id'] = $invoice->currency_id; - $data['company_id'] = $invoice->company_id; - $data['transaction_id'] = $transaction->id; - - return DB::transaction(function () use ($data, $serial, $invoice): Payment { - $payment = Payment::create($data); - $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); - $payment->sequence_number = $serial->nextSequenceNumber; - $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $payment->save(); - - $this->paymentAllocationService->replace($payment, [[ - 'invoice_id' => $invoice->id, - 'amount' => (int) $data['amount'], - ]]); - - return $payment; - }); - } - - private function loadPayment(Payment $payment): Payment - { - return Payment::with([ - 'customer', - 'allocations.invoice', - 'paymentMethod', - 'fields', - ])->findOrFail($payment->id); - } -} diff --git a/app/Services/ExchangeRateProviderService.php b/app/Services/ExchangeRateProviderService.php deleted file mode 100644 index f8c847dc..00000000 --- a/app/Services/ExchangeRateProviderService.php +++ /dev/null @@ -1,120 +0,0 @@ -getExchangeRateProviderPayload()); - } - - public function update(ExchangeRateProvider $provider, ExchangeRateProviderRequest $request): ExchangeRateProvider - { - $provider->update($request->getExchangeRateProviderPayload()); - - return $provider; - } - - public function checkActiveCurrencies($request) - { - $currencies = $request->currencies; - - if (empty($currencies)) { - return collect(); - } - - $query = ExchangeRateProvider::where('active', true); - - foreach ($currencies as $currency) { - $query->orWhere(function ($q) use ($currency) { - $q->where('active', true) - ->whereJsonContains('currencies', $currency); - }); - } - - return $query->get(); - } - - public function checkUpdateActiveCurrencies(ExchangeRateProvider $provider, $request) - { - $currencies = $request->currencies; - - if (empty($currencies)) { - return collect(); - } - - $query = ExchangeRateProvider::where('id', '<>', $provider->id) - ->where('active', true); - - $query->where(function ($q) use ($currencies) { - foreach ($currencies as $currency) { - $q->orWhereJsonContains('currencies', $currency); - } - }); - - return $query->get(); - } - - public function checkProviderStatus($request) - { - try { - $driver = ExchangeRateDriverFactory::make( - $request['driver'], - $request['key'], - $request['driver_config'] ?? [] - ); - - $rates = $driver->validateConnection(); - - return response()->json([ - 'exchangeRate' => $rates, - ], 200); - } catch (ExchangeRateException $e) { - return respondJson($e->errorKey, $e->getMessage()); - } - } - - public function getExchangeRate(string $driver, string $apiKey, array $driverConfig, string $baseCurrency, string $targetCurrency) - { - try { - $driverInstance = ExchangeRateDriverFactory::make($driver, $apiKey, $driverConfig); - - return response()->json([ - 'exchangeRate' => $driverInstance->getExchangeRate($baseCurrency, $targetCurrency), - ], 200); - } catch (ExchangeRateException $e) { - return respondJson($e->errorKey, $e->getMessage()); - } - } - - public function getSupportedCurrencies(string $driver, string $apiKey, array $driverConfig = []) - { - try { - $driverInstance = ExchangeRateDriverFactory::make($driver, $apiKey, $driverConfig); - - return response()->json([ - 'supportedCurrencies' => $driverInstance->getSupportedCurrencies(), - ]); - } catch (ExchangeRateException $e) { - return respondJson($e->errorKey, $e->getMessage()); - } - } - - public function addExchangeRateLog($model): ExchangeRateLog - { - return ExchangeRateLog::create([ - 'exchange_rate' => $model->exchange_rate, - 'company_id' => $model->company_id, - 'base_currency_id' => $model->currency_id, - 'currency_id' => CompanySetting::getSetting('currency', $model->company_id), - ]); - } -} diff --git a/app/Services/ItemService.php b/app/Services/ItemService.php deleted file mode 100644 index de338f3a..00000000 --- a/app/Services/ItemService.php +++ /dev/null @@ -1,49 +0,0 @@ -validated(); - $data['company_id'] = $request->header('company'); - $data['creator_id'] = Auth::id(); - $data['currency_id'] = CompanySetting::getSetting('currency', $request->header('company')); - $item = Item::create($data); - - if ($request->has('taxes')) { - foreach ($request->taxes as $tax) { - $item->tax_per_item = true; - $item->save(); - $tax['company_id'] = $request->header('company'); - $item->taxes()->create($tax); - } - } - - return Item::with('taxes')->find($item->id); - } - - public function update(Item $item, Request $request): Item - { - $item->update($request->validated()); - - $item->taxes()->delete(); - - if ($request->has('taxes')) { - foreach ($request->taxes as $tax) { - $item->tax_per_item = true; - $item->save(); - $tax['company_id'] = $request->header('company'); - $item->taxes()->create($tax); - } - } - - return Item::with('taxes')->find($item->id); - } -} diff --git a/app/Services/Mail/CompanyMailConfigService.php b/app/Services/Mail/CompanyMailConfigService.php deleted file mode 100644 index 4adb61ad..00000000 --- a/app/Services/Mail/CompanyMailConfigService.php +++ /dev/null @@ -1,11 +0,0 @@ -applyCompanyConfig($companyId); - } -} diff --git a/app/Support/Hashids/HashidConnection.php b/app/Support/Hashids/HashidConnection.php new file mode 100644 index 00000000..fa5347c9 --- /dev/null +++ b/app/Support/Hashids/HashidConnection.php @@ -0,0 +1,15 @@ +model_type == Invoice::class) { + if ($media->model_type === ModelIdentityMap::INVOICE_ALIAS) { $folderName = 'Invoices'; - } elseif ($media->model_type == Estimate::class) { + } elseif ($media->model_type === ModelIdentityMap::ESTIMATE_ALIAS) { $folderName = 'Estimates'; - } elseif ($media->model_type == Payment::class) { + } elseif ($media->model_type === ModelIdentityMap::PAYMENT_ALIAS) { $folderName = 'Payments'; } else { $folderName = $media->getKey(); diff --git a/app/Traits/HasCustomFieldsTrait.php b/app/Traits/HasCustomFieldsTrait.php deleted file mode 100644 index 1018edb8..00000000 --- a/app/Traits/HasCustomFieldsTrait.php +++ /dev/null @@ -1,83 +0,0 @@ -morphMany(CustomFieldValue::class, 'custom_field_valuable'); - } - - protected static function booted() - { - static::deleting(function ($data) { - if ($data->fields()->exists()) { - $data->fields()->delete(); - } - }); - } - - public function addCustomFields($customFields) - { - foreach ($customFields as $field) { - if (! is_array($field)) { - $field = (array) $field; - } - $customField = CustomField::find($field['id']); - - $customFieldValue = [ - 'type' => $customField->type, - 'custom_field_id' => $customField->id, - 'company_id' => $customField->company_id, - getCustomFieldValueKey($customField->type) => $field['value'], - ]; - - $this->fields()->create($customFieldValue); - } - } - - public function updateCustomFields($customFields) - { - foreach ($customFields as $field) { - if (! is_array($field)) { - $field = (array) $field; - } - - $customField = CustomField::find($field['id']); - $customFieldValue = $this->fields()->firstOrCreate([ - 'custom_field_id' => $customField->id, - 'type' => $customField->type, - 'company_id' => $this->company_id, - ]); - - $type = getCustomFieldValueKey($customField->type); - $customFieldValue->$type = $field['value']; - $customFieldValue->save(); - } - } - - public function getCustomFieldBySlug($slug) - { - return $this->fields() - ->with('customField') - ->whereHas('customField', function ($query) use ($slug) { - $query->where('slug', $slug); - })->first(); - } - - public function getCustomFieldValueBySlug($slug) - { - $value = $this->getCustomFieldBySlug($slug); - - if ($value) { - return $value->defaultAnswer; - } - - return null; - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index 5fa3255a..61d4a90a 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,27 +1,27 @@ CustomerGuest::class, 'customer-portal' => CustomerPortalMiddleware::class, 'guest' => RedirectIfAuthenticated::class, - 'install' => InstallationMiddleware::class, + 'install' => EnsureInstalled::class, 'not-containerized' => EnsureNotContainerized::class, 'pdf-auth' => PdfMiddleware::class, 'redirect-if-installed' => RedirectIfInstalled::class, diff --git a/bootstrap/providers.php b/bootstrap/providers.php index ce468456..20cd47b1 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,11 +1,22 @@ [ - Invoice::class => [ - 'salt' => Invoice::class.config('app.key'), + HashidConnection::Invoice->value => [ + 'salt' => 'App\\Models\\Invoice'.config('app.key'), 'length' => 20, 'alphabet' => 'XKAR7m8jD2bqP9OSVeNGiYL465T10zhfWuc3', ], - Estimate::class => [ - 'salt' => Estimate::class.config('app.key'), + HashidConnection::Estimate->value => [ + 'salt' => 'App\\Models\\Estimate'.config('app.key'), 'length' => 20, 'alphabet' => 'yJW2P79M8rCHsVq5zbn1fXl6IUt3dAekGo40', ], - Payment::class => [ - 'salt' => Payment::class.config('app.key'), + HashidConnection::Payment->value => [ + 'salt' => 'App\\Models\\Payment'.config('app.key'), 'length' => 20, 'alphabet' => 'aqW3eR2Icf0jp65Gl7UVS1dhyb8Mn9XKTZ4O', ], - Company::class => [ - 'salt' => Company::class.config('app.key'), + HashidConnection::Company->value => [ + 'salt' => 'App\\Models\\Company'.config('app.key'), 'length' => 20, 'alphabet' => 's0D7xOFYEqn2uKJm3Pr9g8Cz46A1iHLBTVW5', ], - EmailLog::class => [ - 'salt' => EmailLog::class.config('app.key'), + HashidConnection::EmailLog->value => [ + 'salt' => 'App\\Models\\EmailLog'.config('app.key'), 'length' => 20, 'alphabet' => 'BA5tJUVNPe93fCq6DHlY2x4ZO1Kg7i8wSm0R', ], - Transaction::class => [ - 'salt' => Transaction::class.config('app.key'), + HashidConnection::Transaction->value => [ + 'salt' => 'App\\Models\\Transaction'.config('app.key'), 'length' => 20, 'alphabet' => 'ADyWE86Cg7jF23vS0bonXrZ5KLH9puIQ4M1T', ], diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index 7f0dbfcc..449cedc7 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -1,15 +1,15 @@ ConsoleServiceProvider::defaultCommands() ->reject(fn (string $command): bool => $command === ModuleDeleteCommand::class) - ->merge([ - UninstallModuleCommand::class, - ])->toArray(), + ->toArray(), /* |-------------------------------------------------------------------------- diff --git a/config/services.php b/config/services.php index c64c38b3..bbc20f05 100644 --- a/config/services.php +++ b/config/services.php @@ -1,6 +1,6 @@ faker->randomElement([Invoice::class, Estimate::class, Payment::class]); + return [ 'from' => $this->faker->unique()->safeEmail(), 'to' => $this->faker->unique()->safeEmail(), 'subject' => $this->faker->sentence(), 'body' => $this->faker->text(), - 'mailable_type' => $this->faker->randomElement([Invoice::class, Estimate::class, Payment::class]), - 'mailable_id' => function (array $log) { - return $log['mailable_type']::factory(); - }, + 'mailable_type' => (new $mailable)->getMorphClass(), + 'mailable_id' => $mailable::factory(), ]; } } diff --git a/database/factories/EstimateFactory.php b/database/factories/EstimateFactory.php index 851c966c..b154c9de 100644 --- a/database/factories/EstimateFactory.php +++ b/database/factories/EstimateFactory.php @@ -2,11 +2,11 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\Customer; -use App\Models\Estimate; -use App\Models\User; -use App\Services\Document\SerialNumberService; +use App\Domains\Accounts\Models\User; +use App\Domains\Contacts\Models\Customer; +use App\Domains\Money\Models\Currency; +use App\Domains\Sales\Application\SerialNumberService; +use App\Domains\Sales\Models\Estimate; use Illuminate\Database\Eloquent\Factories\Factory; class EstimateFactory extends Factory diff --git a/database/factories/EstimateItemFactory.php b/database/factories/EstimateItemFactory.php index 402a328e..53e6cc71 100644 --- a/database/factories/EstimateItemFactory.php +++ b/database/factories/EstimateItemFactory.php @@ -2,10 +2,10 @@ namespace Database\Factories; -use App\Models\Estimate; -use App\Models\EstimateItem; -use App\Models\Item; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Catalog\Models\Item; +use App\Domains\Sales\Models\Estimate; +use App\Domains\Sales\Models\EstimateItem; use Illuminate\Database\Eloquent\Factories\Factory; class EstimateItemFactory extends Factory diff --git a/database/factories/ExchangeRateLogFactory.php b/database/factories/ExchangeRateLogFactory.php index ccbd87e2..feb33b28 100644 --- a/database/factories/ExchangeRateLogFactory.php +++ b/database/factories/ExchangeRateLogFactory.php @@ -2,9 +2,9 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\ExchangeRateLog; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Money\Models\Currency; +use App\Domains\Money\Models\ExchangeRateLog; use Illuminate\Database\Eloquent\Factories\Factory; class ExchangeRateLogFactory extends Factory diff --git a/database/factories/ExchangeRateProviderFactory.php b/database/factories/ExchangeRateProviderFactory.php index 3ee19fc8..457786d4 100644 --- a/database/factories/ExchangeRateProviderFactory.php +++ b/database/factories/ExchangeRateProviderFactory.php @@ -2,7 +2,7 @@ namespace Database\Factories; -use App\Models\ExchangeRateProvider; +use App\Domains\Money\Models\ExchangeRateProvider; use Illuminate\Database\Eloquent\Factories\Factory; class ExchangeRateProviderFactory extends Factory diff --git a/database/factories/ExpenseCategoryFactory.php b/database/factories/ExpenseCategoryFactory.php index 64dde573..760e1522 100644 --- a/database/factories/ExpenseCategoryFactory.php +++ b/database/factories/ExpenseCategoryFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Models\ExpenseCategory; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Purchases\Models\ExpenseCategory; use Illuminate\Database\Eloquent\Factories\Factory; class ExpenseCategoryFactory extends Factory diff --git a/database/factories/ExpenseFactory.php b/database/factories/ExpenseFactory.php index d4c4765d..2ec8be9c 100644 --- a/database/factories/ExpenseFactory.php +++ b/database/factories/ExpenseFactory.php @@ -2,11 +2,11 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\Customer; -use App\Models\Expense; -use App\Models\ExpenseCategory; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Contacts\Models\Customer; +use App\Domains\Money\Models\Currency; +use App\Domains\Purchases\Models\Expense; +use App\Domains\Purchases\Models\ExpenseCategory; use Illuminate\Database\Eloquent\Factories\Factory; class ExpenseFactory extends Factory diff --git a/database/factories/FileDiskFactory.php b/database/factories/FileDiskFactory.php index 3bd97bea..10757ec6 100644 --- a/database/factories/FileDiskFactory.php +++ b/database/factories/FileDiskFactory.php @@ -2,7 +2,7 @@ namespace Database\Factories; -use App\Models\FileDisk; +use App\Platform\Storage\Models\FileDisk; use Illuminate\Database\Eloquent\Factories\Factory; class FileDiskFactory extends Factory diff --git a/database/factories/InvoiceFactory.php b/database/factories/InvoiceFactory.php index 02e9ccbd..5c5f2733 100644 --- a/database/factories/InvoiceFactory.php +++ b/database/factories/InvoiceFactory.php @@ -2,12 +2,12 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\Customer; -use App\Models\Invoice; -use App\Models\RecurringInvoice; -use App\Models\User; -use App\Services\Document\SerialNumberService; +use App\Domains\Accounts\Models\User; +use App\Domains\Contacts\Models\Customer; +use App\Domains\Money\Models\Currency; +use App\Domains\Sales\Application\SerialNumberService; +use App\Domains\Sales\Models\Invoice; +use App\Domains\Sales\Models\RecurringInvoice; use Illuminate\Database\Eloquent\Factories\Factory; class InvoiceFactory extends Factory diff --git a/database/factories/InvoiceItemFactory.php b/database/factories/InvoiceItemFactory.php index d0f2d953..aa973899 100644 --- a/database/factories/InvoiceItemFactory.php +++ b/database/factories/InvoiceItemFactory.php @@ -2,10 +2,10 @@ namespace Database\Factories; -use App\Models\InvoiceItem; -use App\Models\Item; -use App\Models\RecurringInvoice; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Catalog\Models\Item; +use App\Domains\Sales\Models\InvoiceItem; +use App\Domains\Sales\Models\RecurringInvoice; use Illuminate\Database\Eloquent\Factories\Factory; class InvoiceItemFactory extends Factory diff --git a/database/factories/ItemFactory.php b/database/factories/ItemFactory.php index 67b7ad87..2fba8343 100644 --- a/database/factories/ItemFactory.php +++ b/database/factories/ItemFactory.php @@ -2,10 +2,10 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\Item; -use App\Models\Unit; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Catalog\Models\Item; +use App\Domains\Catalog\Models\Unit; +use App\Domains\Money\Models\Currency; use Illuminate\Database\Eloquent\Factories\Factory; class ItemFactory extends Factory diff --git a/database/factories/NoteFactory.php b/database/factories/NoteFactory.php index 3637faae..d863a0ae 100644 --- a/database/factories/NoteFactory.php +++ b/database/factories/NoteFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Models\Note; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Metadata\Models\Note; use Illuminate\Database\Eloquent\Factories\Factory; class NoteFactory extends Factory diff --git a/database/factories/PaymentAllocationFactory.php b/database/factories/PaymentAllocationFactory.php index dfe258c2..9cb3d067 100644 --- a/database/factories/PaymentAllocationFactory.php +++ b/database/factories/PaymentAllocationFactory.php @@ -2,9 +2,9 @@ namespace Database\Factories; -use App\Models\Invoice; -use App\Models\Payment; -use App\Models\PaymentAllocation; +use App\Domains\Receivables\Models\Payment; +use App\Domains\Receivables\Models\PaymentAllocation; +use App\Domains\Sales\Models\Invoice; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -12,6 +12,13 @@ use Illuminate\Database\Eloquent\Factories\Factory; */ class PaymentAllocationFactory extends Factory { + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = PaymentAllocation::class; + /** * Define the model's default state. * diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php index c7e8b723..3ef8603b 100644 --- a/database/factories/PaymentFactory.php +++ b/database/factories/PaymentFactory.php @@ -2,12 +2,12 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\Customer; -use App\Models\Payment; -use App\Models\PaymentMethod; -use App\Models\User; -use App\Services\Document\SerialNumberService; +use App\Domains\Accounts\Models\User; +use App\Domains\Contacts\Models\Customer; +use App\Domains\Money\Models\Currency; +use App\Domains\Receivables\Models\Payment; +use App\Domains\Receivables\Models\PaymentMethod; +use App\Domains\Sales\Application\SerialNumberService; use Illuminate\Database\Eloquent\Factories\Factory; class PaymentFactory extends Factory diff --git a/database/factories/PaymentMethodFactory.php b/database/factories/PaymentMethodFactory.php index f1c15631..46c3daef 100644 --- a/database/factories/PaymentMethodFactory.php +++ b/database/factories/PaymentMethodFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Models\PaymentMethod; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Receivables\Models\PaymentMethod; use Illuminate\Database\Eloquent\Factories\Factory; class PaymentMethodFactory extends Factory diff --git a/database/factories/RecurringInvoiceFactory.php b/database/factories/RecurringInvoiceFactory.php index 61e5e40d..fbd1a3bc 100644 --- a/database/factories/RecurringInvoiceFactory.php +++ b/database/factories/RecurringInvoiceFactory.php @@ -2,9 +2,9 @@ namespace Database\Factories; -use App\Models\Customer; -use App\Models\RecurringInvoice; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Contacts\Models\Customer; +use App\Domains\Sales\Models\RecurringInvoice; use Illuminate\Database\Eloquent\Factories\Factory; class RecurringInvoiceFactory extends Factory diff --git a/database/factories/TaxFactory.php b/database/factories/TaxFactory.php index f93cb3a0..19165a37 100644 --- a/database/factories/TaxFactory.php +++ b/database/factories/TaxFactory.php @@ -2,10 +2,10 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\Tax; -use App\Models\TaxType; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Money\Models\Currency; +use App\Domains\Taxation\Models\Tax; +use App\Domains\Taxation\Models\TaxType; use Illuminate\Database\Eloquent\Factories\Factory; class TaxFactory extends Factory diff --git a/database/factories/TaxTypeFactory.php b/database/factories/TaxTypeFactory.php index 004eaf12..86aee36b 100644 --- a/database/factories/TaxTypeFactory.php +++ b/database/factories/TaxTypeFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Models\TaxType; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Taxation\Models\TaxType; use Illuminate\Database\Eloquent\Factories\Factory; class TaxTypeFactory extends Factory diff --git a/database/factories/UnitFactory.php b/database/factories/UnitFactory.php index f3be3466..62cfa122 100644 --- a/database/factories/UnitFactory.php +++ b/database/factories/UnitFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Models\Unit; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Catalog\Models\Unit; use Illuminate\Database\Eloquent\Factories\Factory; class UnitFactory extends Factory diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 92156f00..ff104e1a 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,8 +2,8 @@ namespace Database\Factories; -use App\Models\Currency; -use App\Models\User; +use App\Domains\Accounts\Models\User; +use App\Domains\Money\Models\Currency; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; diff --git a/database/migrations/2020_12_02_064933_update_crater_version_320.php b/database/migrations/2020_12_02_064933_update_crater_version_320.php index 02a0ad53..80736667 100644 --- a/database/migrations/2020_12_02_064933_update_crater_version_320.php +++ b/database/migrations/2020_12_02_064933_update_crater_version_320.php @@ -1,6 +1,6 @@ slug = Str::slug($company->name); $company->save(); - $company->setupRoles(); + app(CompanyService::class)->setupRoles($company); $user->assign('super admin'); $users = User::where('role', 'admin')->get(); diff --git a/database/migrations/2021_07_08_110940_add_company_to_notes_table.php b/database/migrations/2021_07_08_110940_add_company_to_notes_table.php index 1844a72c..b99247d2 100644 --- a/database/migrations/2021_07_08_110940_add_company_to_notes_table.php +++ b/database/migrations/2021_07_08_110940_add_company_to_notes_table.php @@ -1,7 +1,7 @@ > + */ + public const COLUMNS = [ + 'media' => ['model_type'], + 'email_logs' => ['mailable_type'], + 'notifications' => ['notifiable_type'], + 'personal_access_tokens' => ['tokenable_type'], + 'custom_field_values' => ['custom_field_valuable_type'], + 'abilities' => ['entity_type'], + 'assigned_roles' => ['entity_type', 'restricted_to_type'], + 'permissions' => ['entity_type'], + ]; + + /** + * Stable alias => legacy model basename. + * + * @var array + */ + public const FIRST_PARTY_ALIASES = [ + 'address' => 'Address', + 'ai_conversation' => 'AiConversation', + 'ai_message' => 'AiMessage', + 'company' => 'Company', + 'company_invitation' => 'CompanyInvitation', + 'company_setting' => 'CompanySetting', + 'country' => 'Country', + 'currency' => 'Currency', + 'custom_field' => 'CustomField', + 'custom_field_value' => 'CustomFieldValue', + 'customer' => 'Customer', + 'email_log' => 'EmailLog', + 'estimate' => 'Estimate', + 'estimate_item' => 'EstimateItem', + 'exchange_rate_log' => 'ExchangeRateLog', + 'exchange_rate_provider' => 'ExchangeRateProvider', + 'expense' => 'Expense', + 'expense_category' => 'ExpenseCategory', + 'file_disk' => 'FileDisk', + 'impersonation_log' => 'ImpersonationLog', + 'invoice' => 'Invoice', + 'invoice_item' => 'InvoiceItem', + 'item' => 'Item', + 'marketplace_credential' => 'MarketplaceCredential', + 'marketplace_operation' => 'MarketplaceOperation', + 'module' => 'Module', + 'note' => 'Note', + 'payment' => 'Payment', + 'payment_allocation' => 'PaymentAllocation', + 'payment_method' => 'PaymentMethod', + 'recurring_invoice' => 'RecurringInvoice', + 'setting' => 'Setting', + 'tax' => 'Tax', + 'tax_type' => 'TaxType', + 'transaction' => 'Transaction', + 'unit' => 'Unit', + 'user' => 'User', + 'user_setting' => 'UserSetting', + ]; + + /** @var array}> */ + public const VENDOR_ALIASES = [ + 'bouncer_ability' => [ + 'legacy' => 'abilities', + 'types' => ['abilities', Ability::class], + ], + 'bouncer_role' => [ + 'legacy' => 'roles', + 'types' => ['roles', Role::class], + ], + ]; + + public function up(): void + { + $this->replaceTypes(true); + } + + public function down(): void + { + $this->replaceTypes(false); + } + + private function replaceTypes(bool $up): void + { + foreach (self::COLUMNS as $table => $columns) { + if (! Schema::hasTable($table)) { + continue; + } + + foreach ($columns as $column) { + if (! Schema::hasColumn($table, $column)) { + continue; + } + + foreach (self::FIRST_PARTY_ALIASES as $alias => $basename) { + $legacyTypes = $up + ? [ + $alias, + 'App\\Models\\'.$basename, + 'App\\'.$basename, + 'InvoiceShelf\\Models\\'.$basename, + 'InvoiceShelf\\'.$basename, + 'Crater\\Models\\'.$basename, + 'Crater\\'.$basename, + ] + : [$alias]; + + DB::table($table) + ->whereIn($column, $legacyTypes) + ->update([$column => $up ? $alias : 'App\\Models\\'.$basename]); + } + + foreach (self::VENDOR_ALIASES as $alias => $mapping) { + DB::table($table) + ->whereIn($column, $up ? [$alias, ...$mapping['types']] : [$alias]) + ->update([$column => $up ? $alias : $mapping['legacy']]); + } + } + } + } +}; diff --git a/database/seeders/CurrenciesTableSeeder.php b/database/seeders/CurrenciesTableSeeder.php index 29bb658f..e4476c66 100644 --- a/database/seeders/CurrenciesTableSeeder.php +++ b/database/seeders/CurrenciesTableSeeder.php @@ -2,7 +2,7 @@ namespace Database\Seeders; -use App\Models\Currency; +use App\Domains\Money\Models\Currency; use Illuminate\Database\Seeder; class CurrenciesTableSeeder extends Seeder diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php index dba53a1b..d1634df9 100644 --- a/database/seeders/DemoSeeder.php +++ b/database/seeders/DemoSeeder.php @@ -2,15 +2,16 @@ namespace Database\Seeders; +use App\Domains\Accounts\Application\CompanyService; +use App\Domains\Accounts\Models\Company; +use App\Domains\Accounts\Models\CompanySetting; +use App\Domains\Accounts\Models\User; +use App\Domains\Contacts\Models\Country; +use App\Domains\Contacts\Models\Customer; +use App\Domains\Money\Models\Currency; use App\Facades\Hashids; -use App\Models\Company; -use App\Models\CompanySetting; -use App\Models\Country; -use App\Models\Currency; -use App\Models\Customer; -use App\Models\Setting; -use App\Models\User; -use App\Services\Company\CompanyService; +use App\Platform\Operations\Models\Setting; +use App\Support\Hashids\HashidConnection; use Illuminate\Database\Seeder; use Silber\Bouncer\BouncerFacade; @@ -38,7 +39,7 @@ class DemoSeeder extends Seeder 'tax_id' => '84-1234567', ]); - $company->unique_hash = Hashids::connection(Company::class)->encode($company->id); + $company->unique_hash = Hashids::connection(HashidConnection::Company->value)->encode($company->id); $company->save(); app(CompanyService::class)->setupDefaults($company); diff --git a/database/seeders/RealisticDemoSeeder.php b/database/seeders/RealisticDemoSeeder.php index fc793663..94ba0401 100644 --- a/database/seeders/RealisticDemoSeeder.php +++ b/database/seeders/RealisticDemoSeeder.php @@ -2,34 +2,35 @@ namespace Database\Seeders; +use App\Domains\Accounts\Models\Company; +use App\Domains\Accounts\Models\CompanySetting; +use App\Domains\Accounts\Models\User; +use App\Domains\Catalog\Models\Item; +use App\Domains\Catalog\Models\Unit; +use App\Domains\Contacts\Models\Address; +use App\Domains\Contacts\Models\Country; +use App\Domains\Contacts\Models\Customer; +use App\Domains\Metadata\Models\CustomField; +use App\Domains\Metadata\Models\Note; +use App\Domains\Money\Models\Currency; +use App\Domains\Purchases\Models\Expense; +use App\Domains\Purchases\Models\ExpenseCategory; +use App\Domains\Receivables\Application\PaymentAllocationService; +use App\Domains\Receivables\Jobs\GeneratePaymentPdfJob; +use App\Domains\Receivables\Models\Payment; +use App\Domains\Receivables\Models\PaymentAllocation; +use App\Domains\Receivables\Models\PaymentMethod; +use App\Domains\Sales\Application\SerialNumberService; +use App\Domains\Sales\Models\Estimate; +use App\Domains\Sales\Models\EstimateItem; +use App\Domains\Sales\Models\Invoice; +use App\Domains\Sales\Models\InvoiceItem; +use App\Domains\Sales\Models\RecurringInvoice; +use App\Domains\Taxation\Models\Tax; +use App\Domains\Taxation\Models\TaxType; use App\Facades\Hashids; -use App\Jobs\GeneratePaymentPdfJob; -use App\Models\Address; -use App\Models\AiConversation; -use App\Models\Company; -use App\Models\CompanySetting; -use App\Models\Country; -use App\Models\Currency; -use App\Models\Customer; -use App\Models\CustomField; -use App\Models\Estimate; -use App\Models\EstimateItem; -use App\Models\Expense; -use App\Models\ExpenseCategory; -use App\Models\Invoice; -use App\Models\InvoiceItem; -use App\Models\Item; -use App\Models\Note; -use App\Models\Payment; -use App\Models\PaymentAllocation; -use App\Models\PaymentMethod; -use App\Models\RecurringInvoice; -use App\Models\Tax; -use App\Models\TaxType; -use App\Models\Unit; -use App\Models\User; -use App\Services\Document\PaymentAllocationService; -use App\Services\Document\SerialNumberService; +use App\Platform\Ai\Models\AiConversation; +use App\Support\Hashids\HashidConnection; use Carbon\Carbon; use Illuminate\Database\Seeder; use Illuminate\Support\Facades\Artisan; @@ -522,7 +523,7 @@ class RealisticDemoSeeder extends Seeder $invoice->sequence_number = $serial->nextSequenceNumber; $invoice->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $invoice->unique_hash = Hashids::connection(Invoice::class)->encode($invoice->id); + $invoice->unique_hash = Hashids::connection(HashidConnection::Invoice->value)->encode($invoice->id); $invoice->created_at = $invoiceDate; $invoice->updated_at = $invoiceDate; $invoice->save(); @@ -599,7 +600,7 @@ class RealisticDemoSeeder extends Seeder $payment->sequence_number = $serial->nextSequenceNumber; $payment->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $payment->unique_hash = Hashids::connection(Payment::class)->encode($payment->id); + $payment->unique_hash = Hashids::connection(HashidConnection::Payment->value)->encode($payment->id); $payment->created_at = $paymentDate; $payment->updated_at = $paymentDate; Payment::withoutEvents(fn () => $payment->save()); @@ -699,7 +700,7 @@ class RealisticDemoSeeder extends Seeder $estimate->sequence_number = $serial->nextSequenceNumber; $estimate->customer_sequence_number = $serial->nextCustomerSequenceNumber; - $estimate->unique_hash = Hashids::connection(Estimate::class)->encode($estimate->id); + $estimate->unique_hash = Hashids::connection(HashidConnection::Estimate->value)->encode($estimate->id); $estimate->created_at = $estimateDate; $estimate->updated_at = $estimateDate; $estimate->save(); diff --git a/database/seeders/UsersTableSeeder.php b/database/seeders/UsersTableSeeder.php index f414459e..c828778b 100644 --- a/database/seeders/UsersTableSeeder.php +++ b/database/seeders/UsersTableSeeder.php @@ -2,12 +2,13 @@ namespace Database\Seeders; +use App\Domains\Accounts\Application\CompanyService; +use App\Domains\Accounts\Models\Company; +use App\Domains\Accounts\Models\User; use App\Facades\Hashids; -use App\Models\Company; -use App\Models\Setting; -use App\Models\User; -use App\Services\Company\CompanyService; -use App\Support\Setup\InstallUtils; +use App\Platform\Operations\Installation\Application\InstallationState; +use App\Platform\Operations\Models\Setting; +use App\Support\Hashids\HashidConnection; use Illuminate\Database\Seeder; use Silber\Bouncer\BouncerFacade; @@ -31,7 +32,7 @@ class UsersTableSeeder extends Seeder 'slug' => 'xyz', ]); - $company->unique_hash = Hashids::connection(Company::class)->encode($company->id); + $company->unique_hash = Hashids::connection(HashidConnection::Company->value)->encode($company->id); $company->save(); app(CompanyService::class)->setupDefaults($company); $user->companies()->attach($company->id); @@ -41,6 +42,6 @@ class UsersTableSeeder extends Seeder Setting::setSetting('profile_complete', 0); // Set version. - InstallUtils::setCurrentVersion(); + InstallationState::setCurrentVersion(); } } diff --git a/docs/architecture/0001-modular-monolith.md b/docs/architecture/0001-modular-monolith.md new file mode 100644 index 00000000..e55d2140 --- /dev/null +++ b/docs/architecture/0001-modular-monolith.md @@ -0,0 +1,63 @@ +# ADR 0001: Laravel-native modular monolith + +Date: 2026-08-05 + +Status: Accepted + +## Context + +InvoiceShelf 3.x is still in alpha and may break PHP namespaces and internal +extension contracts. Existing installations must retain their database data, +document links, and public `/api/v1` behavior. The current layer-first layout +mixes sales, receivables, purchases, reporting, and infrastructure in global +model, controller, and service directories. + +## Decision + +The backend is organized as a modular monolith under `app/Domains` and +`app/Platform`. + +Business contexts are Accounts, Contacts, Catalog, Taxation, Money, Metadata, +Sales, Receivables, Purchases, and Reporting. Platform capabilities are +Modules, AI, Mail, PDF, Storage, and Operations. + +Each context owns its models, actions, queries, policies, events, jobs, and HTTP +adapters. Large contexts may contain feature subdirectories. Every context is +registered explicitly through a service provider; root route files are +composition roots for context-owned route fragments. + +Direct Eloquent relationships across contexts are permitted for read +navigation when declared by the architecture dependency map. Cross-context +writes and workflows use contracts and application actions. Reporting may +query shared tables through read-only query objects. Infrastructure is reached +through platform contracts. No repository abstraction is required around +Eloquent by default. + +All first-party models use stable morph aliases. Model namespaces, Hashids +connection names, and public API discriminators are separate identities. The +database stores aliases, Hashids retain their historical salt inputs, and v1 +resources retain their existing discriminator strings. + +Modules depend on the versioned `invoiceshelf/modules` SDK and must not import +domain internals. + +## Migration rules + +- Contexts move in independently green pull requests. +- A moved class has one canonical namespace; class aliases are forbidden. +- Temporary adapters may connect migrated and unmigrated contexts, but must be + deleted when the counterpart context moves. +- Existing table and column names remain unchanged and moved models declare + their table explicitly. +- HTTP methods, paths, middleware, payloads, status codes, and OpenAPI schemas + remain stable. +- Queue workers are drained and restarted for the namespace cutover; serialized + legacy jobs are not supported. + +## Consequences + +Feature ownership and cross-domain writes become explicit, while Eloquent +remains usable without a framework-independent persistence layer. Namespace +changes require coordinated imports across application code, factories, +seeders, configuration, tests, and historical migrations, but no persisted +record depends on those namespaces after the alias migration. diff --git a/resources/scripts/features/admin/components/settings/pdfPageSetup.ts b/resources/scripts/features/admin/components/settings/pdfPageSetup.ts index 21fed0d7..b338bdf6 100644 --- a/resources/scripts/features/admin/components/settings/pdfPageSetup.ts +++ b/resources/scripts/features/admin/components/settings/pdfPageSetup.ts @@ -16,7 +16,7 @@ export const PAGE_SETUP_KEYS = [ 'pdf_margin_left', ] as const -/** Mirrors App\Rules\CssLength, so a bad value is caught before the round trip. */ +/** Mirrors App\Platform\Pdf\Rules\CssLength, so a bad value is caught before the round trip. */ const CSS_LENGTH = /^(0|\d+(\.\d+)?(pt|px|pc|mm|cm|in))$/ export function cssLength(t: (key: string) => string) { diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 1de8b02b..c4823f67 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -18,7 +18,7 @@ @foreach(\InvoiceShelf\Modules\Registry::allStyles() as $name => $path) - @php($version = \App\Support\Module\ModuleAssetVersion::forPath($path)) + @php($version = \App\Platform\Modules\Runtime\ModuleAssetVersion::forPath($path)) @endforeach @@ -43,7 +43,7 @@ @if (\Illuminate\Support\Str::startsWith($path, ['http://', 'https://'])) @else - @php($version = \App\Support\Module\ModuleAssetVersion::forPath($path)) + @php($version = \App\Platform\Modules\Runtime\ModuleAssetVersion::forPath($path)) @endif @endforeach diff --git a/resources/views/app/pdf/estimate/estimate1.blade.php b/resources/views/app/pdf/estimate/estimate1.blade.php index 83a545d3..647a49db 100644 --- a/resources/views/app/pdf/estimate/estimate1.blade.php +++ b/resources/views/app/pdf/estimate/estimate1.blade.php @@ -420,7 +420,7 @@ @if ($logo) - + @else @if ($estimate->customer->company) diff --git a/resources/views/app/pdf/estimate/estimate2.blade.php b/resources/views/app/pdf/estimate/estimate2.blade.php index 1f5074e0..cd22250b 100644 --- a/resources/views/app/pdf/estimate/estimate2.blade.php +++ b/resources/views/app/pdf/estimate/estimate2.blade.php @@ -439,7 +439,7 @@ @if ($logo) - + @else diff --git a/resources/views/app/pdf/estimate/estimate3.blade.php b/resources/views/app/pdf/estimate/estimate3.blade.php index eea2b580..d34fefb1 100644 --- a/resources/views/app/pdf/estimate/estimate3.blade.php +++ b/resources/views/app/pdf/estimate/estimate3.blade.php @@ -381,7 +381,7 @@ @if ($logo) - + @else

{{ $estimate->customer->company->name }}

@endif diff --git a/resources/views/app/pdf/invoice/invoice1.blade.php b/resources/views/app/pdf/invoice/invoice1.blade.php index 2c7a84fd..0b959e4b 100644 --- a/resources/views/app/pdf/invoice/invoice1.blade.php +++ b/resources/views/app/pdf/invoice/invoice1.blade.php @@ -363,7 +363,7 @@ @if ($logo) - + @else @if ($invoice->customer->company) diff --git a/resources/views/app/pdf/invoice/invoice2.blade.php b/resources/views/app/pdf/invoice/invoice2.blade.php index a526ef6c..2fa038f9 100644 --- a/resources/views/app/pdf/invoice/invoice2.blade.php +++ b/resources/views/app/pdf/invoice/invoice2.blade.php @@ -408,7 +408,7 @@ @if ($logo) - + @elseif ($invoice->customer->company)

{{ $invoice->customer->company->name }} diff --git a/resources/views/app/pdf/invoice/invoice3.blade.php b/resources/views/app/pdf/invoice/invoice3.blade.php index 884aa9ad..35ec7b25 100644 --- a/resources/views/app/pdf/invoice/invoice3.blade.php +++ b/resources/views/app/pdf/invoice/invoice3.blade.php @@ -341,7 +341,7 @@ @if ($logo) - + @else

{{ $invoice->customer->company->name }}

@endif diff --git a/resources/views/app/pdf/partials/credit-note-banner.blade.php b/resources/views/app/pdf/partials/credit-note-banner.blade.php index d9e47a45..430b2c3f 100644 --- a/resources/views/app/pdf/partials/credit-note-banner.blade.php +++ b/resources/views/app/pdf/partials/credit-note-banner.blade.php @@ -11,7 +11,7 @@ Every style is inline: a body-included partial cannot add rules to , and inline styles are the one thing dompdf and Chromium honour identically. --}} -@if (isset($invoice) && $invoice instanceof \App\Models\Invoice) +@if (isset($invoice) && $invoice instanceof \App\Domains\Sales\Models\Invoice) @php $bannerRelatedInvoice = $invoice->relationLoaded('relatedInvoice') ? $invoice->getRelation('relatedInvoice') : null; $bannerCreditNotes = $invoice->relationLoaded('creditNotes') ? $invoice->getRelation('creditNotes') : null; diff --git a/resources/views/app/pdf/partials/fonts.blade.php b/resources/views/app/pdf/partials/fonts.blade.php index 7fd7d2b6..fbd7100c 100644 --- a/resources/views/app/pdf/partials/fonts.blade.php +++ b/resources/views/app/pdf/partials/fonts.blade.php @@ -2,9 +2,9 @@ FontService::getInstalledFontFaces(). Bundled packages live under resources/static/fonts/, on-demand packages under storage/fonts/. --}} diff --git a/resources/views/app/pdf/payment/payment.blade.php b/resources/views/app/pdf/payment/payment.blade.php index 529b9cb4..4860b8b3 100644 --- a/resources/views/app/pdf/payment/payment.blade.php +++ b/resources/views/app/pdf/payment/payment.blade.php @@ -294,7 +294,7 @@ @if ($logo) - + @else @if ($payment->customer) diff --git a/resources/views/app/pdf/reports/customer-statement.blade.php b/resources/views/app/pdf/reports/customer-statement.blade.php index b039eb7a..0e504535 100644 --- a/resources/views/app/pdf/reports/customer-statement.blade.php +++ b/resources/views/app/pdf/reports/customer-statement.blade.php @@ -10,7 +10,7 @@ @if (! empty($logo)) - + @else

{{ $company->name }}

@endif diff --git a/resources/views/app/pdf/reports/partials/layout.blade.php b/resources/views/app/pdf/reports/partials/layout.blade.php index 05a25c1b..2b9906d2 100644 --- a/resources/views/app/pdf/reports/partials/layout.blade.php +++ b/resources/views/app/pdf/reports/partials/layout.blade.php @@ -28,7 +28,7 @@ @if (! empty($logo)) - + @else

{{ $company->name }}

@endif diff --git a/routes/api.php b/routes/api.php index d3ca5cc9..3f739d90 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,89 +1,5 @@ group(function () { // App version // ---------------------------------- - Route::get('/app/version', AppVersionController::class); + require app_path('Platform/Operations/routes/version.php'); // Authentication & Password Reset // ---------------------------------- - Route::prefix('auth')->group(function () { - Route::post('login', [AuthController::class, 'login']); - - Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:sanctum'); - - // Send reset password mail - Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:10,2'); - - // handle reset password form process - Route::post('reset/password', [ResetPasswordController::class, 'reset']); - }); - - // Invitation Registration (public) - // ---------------------------------- - - Route::get('/invitations/{token}/details', [InvitationRegistrationController::class, 'details']); - Route::post('/auth/register-with-invitation', [InvitationRegistrationController::class, 'register']); + require app_path('Domains/Accounts/routes/public.php'); // Countries // ---------------------------------- - Route::get('/countries', CountriesController::class); + require app_path('Domains/Contacts/routes/public.php'); // Onboarding // ---------------------------------- Route::middleware(['redirect-if-installed'])->prefix('installation')->group(function () { - Route::get('/wizard-step', [OnboardingWizardController::class, 'getStep']); - - Route::post('/wizard-step', [OnboardingWizardController::class, 'updateStep']); - - Route::post('/wizard-language', [OnboardingWizardController::class, 'saveLanguage']); - - Route::get('/languages', [LanguagesController::class, 'languages']); - - Route::get('/requirements', [RequirementsController::class, 'requirements']); - - Route::get('/permissions', [FilePermissionsController::class, 'permissions']); - - Route::post('/database/config', [DatabaseConfigurationController::class, 'saveDatabaseEnvironment']); - - Route::get('/database/config', [DatabaseConfigurationController::class, 'getDatabaseEnvironment']); - - Route::put('/set-domain', AppDomainController::class); - - Route::get('/ai/config', [InstallerAiConfigurationController::class, 'show']); - Route::post('/ai/config', [InstallerAiConfigurationController::class, 'save']); - - Route::post('/login', LoginController::class); - - Route::post('/finish', FinishController::class); + require app_path('Platform/Operations/Installation/routes/api.php'); + require app_path('Platform/Ai/routes/installer.php'); }); // Super Admin // ---------------------------------- Route::middleware(['auth:sanctum', 'super-admin'])->prefix('super-admin')->group(function () { - Route::get('dashboard', [AdminDashboardController::class, 'index']); - Route::get('companies', [CompaniesController::class, 'index']); - Route::get('companies/{company}', [CompaniesController::class, 'show']); - Route::put('companies/{company}', [CompaniesController::class, 'update']); - - Route::get('users', [UsersController::class, 'index']); - Route::get('users/{user}', [UsersController::class, 'show']); - Route::put('users/{user}', [UsersController::class, 'update']); - Route::post('users/{user}/impersonate', [UsersController::class, 'impersonate']); + require app_path('Platform/Operations/routes/admin.php'); + require app_path('Domains/Accounts/routes/admin.php'); }); // Stop impersonation - uses auth:sanctum only (the impersonated user's token, not super-admin) Route::middleware(['auth:sanctum'])->prefix('super-admin')->group(function () { - Route::post('stop-impersonating', [UsersController::class, 'stopImpersonating']); + require app_path('Domains/Accounts/routes/impersonation.php'); }); Route::middleware(['auth:sanctum', 'company'])->group(function () { Route::middleware(['bouncer'])->group(function () { - - // Bootstrap - // ---------------------------------- - - Route::get('/bootstrap', BootstrapController::class); - - // Invitations (user-scoped — respond to invitations) - // ---------------------------------- - - Route::get('/invitations/pending', [InvitationResponseController::class, 'pending']); - Route::post('/invitations/{invitation:token}/accept', [InvitationResponseController::class, 'accept']); - Route::post('/invitations/{invitation:token}/decline', [InvitationResponseController::class, 'decline']); + require app_path('Domains/Accounts/routes/company.php'); + require app_path('Platform/Operations/routes/company.php'); // Currencies // ---------------------------------- - Route::prefix('/currencies')->group(function () { - Route::get('/used', [ExchangeRateProviderController::class, 'usedCurrenciesWithoutRate']); + require app_path('Domains/Money/routes/company.php'); - Route::post('/bulk-update-exchange-rate', [ExchangeRateProviderController::class, 'bulkUpdate']); - }); - - // Dashboard + // Reporting and customers // ---------------------------------- - Route::get('/dashboard', DashboardController::class); - - // Auth check - // ---------------------------------- - - Route::get('/auth/check', [AuthController::class, 'check']); - - // Search users - // ---------------------------------- - - Route::get('/search', SearchController::class); - - Route::get('/search/user', [SearchController::class, 'users']); - - // MISC - // ---------------------------------- - - Route::get('/config', ConfigController::class); - - Route::get('/currencies', CurrenciesController::class); - - Route::get('/timezones', [FormatsController::class, 'timezones']); - - Route::get('/date/formats', [FormatsController::class, 'dateFormats']); - - Route::get('/time/formats', [FormatsController::class, 'timeFormats']); - - Route::get('/next-number', [SerialNumberController::class, 'nextNumber']); - - Route::get('/number-placeholders', [SerialNumberController::class, 'placeholders']); - - Route::get('/current-company', [BootstrapController::class, 'currentCompany']); - - // Company Invitations (company-scoped — send invitations) - // ---------------------------------- - - Route::apiResource('company-invitations', InvitationController::class)->only(['index', 'store', 'destroy']); - - // Customers - // ---------------------------------- - - Route::post('/customers/delete', [CustomersController::class, 'delete']); - - Route::get('customers/{customer}/stats', CustomerStatsController::class); - - Route::get('customers/{customer}/statement', CustomerStatementController::class); - Route::post('customers/{customer}/statement/send', SendCustomerStatementController::class); - Route::post('customers/{customer}/credit-allocations', [CreditAllocationsController::class, 'store']); - - Route::resource('customers', CustomersController::class); + require app_path('Domains/Reporting/routes/company.php'); + require app_path('Domains/Contacts/routes/company.php'); // Items // ---------------------------------- - Route::post('/items/delete', [ItemsController::class, 'delete']); + require app_path('Domains/Catalog/routes/company.php'); - Route::resource('items', ItemsController::class); - - Route::resource('units', UnitsController::class); - - // Invoices + // Sales documents // ------------------------------------------------- - Route::get('/invoices/{invoice}/send/preview', [InvoicesController::class, 'sendPreview']); - - Route::post('/invoices/{invoice}/send', [InvoicesController::class, 'send']); - - Route::post('/invoices/{invoice}/clone', [InvoicesController::class, 'clone']); - - Route::post('/invoices/{invoice}/convert-to-estimate', [InvoicesController::class, 'convertToEstimate']); - - Route::post('/invoices/{invoice}/credit-note', [InvoicesController::class, 'createCreditNote']); - - Route::post('/invoices/{invoice}/status', [InvoicesController::class, 'changeStatus']); - - Route::post('/invoices/delete', [InvoicesController::class, 'delete']); - - Route::get('/invoices/templates', InvoiceTemplatesController::class); - - Route::apiResource('invoices', InvoicesController::class); - - // Recurring Invoice - // ------------------------------------------------- - - Route::get('/recurring-invoice-frequency', RecurringInvoiceFrequencyController::class); - - Route::post('/recurring-invoices/delete', [RecurringInvoiceController::class, 'delete']); - - Route::apiResource('recurring-invoices', RecurringInvoiceController::class); - - // Estimates - // ------------------------------------------------- - - Route::get('/estimates/{estimate}/send/preview', [EstimatesController::class, 'sendPreview']); - - Route::post('/estimates/{estimate}/send', [EstimatesController::class, 'send']); - - Route::post('/estimates/{estimate}/clone', [EstimatesController::class, 'clone']); - - Route::post('/estimates/{estimate}/status', [EstimatesController::class, 'changeStatus']); - - Route::post('/estimates/{estimate}/convert-to-invoice', [EstimatesController::class, 'convertToInvoice']); - - Route::get('/estimates/templates', EstimateTemplatesController::class); - - Route::post('/estimates/delete', [EstimatesController::class, 'delete']); - - Route::apiResource('estimates', EstimatesController::class); + require app_path('Domains/Sales/routes/company.php'); // Expenses // ---------------------------------- - Route::get('/expenses/{expense}/show/receipt', [ExpensesController::class, 'showReceipt']); - - Route::post('/expenses/{expense}/upload/receipts', [ExpensesController::class, 'uploadReceipt']); - - Route::post('/expenses/delete', [ExpensesController::class, 'delete']); - - Route::apiResource('expenses', ExpensesController::class); - - Route::apiResource('categories', ExpenseCategoriesController::class); + require app_path('Domains/Purchases/routes/company.php'); // Payments // ---------------------------------- - Route::get('/payments/{payment}/send/preview', [PaymentsController::class, 'sendPreview']); - - Route::post('/payments/{payment}/send', [PaymentsController::class, 'send']); - - Route::put('/payments/{payment}/allocations', [PaymentsController::class, 'replaceAllocations']); - - Route::post('/payments/delete', [PaymentsController::class, 'delete']); - - Route::apiResource('payments', PaymentsController::class); - - Route::apiResource('payment-methods', PaymentMethodsController::class); + require app_path('Domains/Receivables/routes/company.php'); // Custom fields // ---------------------------------- - Route::resource('custom-fields', CustomFieldsController::class); + require app_path('Domains/Metadata/routes/company.php'); // Backup & Disk // ---------------------------------- - Route::apiResource('backups', BackupsController::class); + require app_path('Platform/Storage/routes/company.php'); - Route::apiResource('/disks', DiskController::class); - - Route::get('download-backup', [BackupsController::class, 'download']); - - Route::get('/disk/drivers', [DiskController::class, 'getDiskDrivers']); - Route::get('/disk/purposes', [DiskController::class, 'getDiskPurposes']); - Route::put('/disk/purposes', [DiskController::class, 'updateDiskPurposes']); - - // Fonts + // PDF rendering and fonts // ---------------------------------- - Route::get('/fonts/status', [FontController::class, 'status']); - Route::post('/fonts/{package}/install', [FontController::class, 'install']); + require app_path('Platform/Pdf/routes/admin.php'); - // Exchange Rate - // ---------------------------------- - - Route::get('/currencies/{currency}/exchange-rate', [ExchangeRateProviderController::class, 'getRate']); - - Route::get('/currencies/{currency}/active-provider', [ExchangeRateProviderController::class, 'activeProvider']); - - Route::get('/used-currencies', [ExchangeRateProviderController::class, 'usedCurrencies']); - - Route::get('/supported-currencies', [ExchangeRateProviderController::class, 'supportedCurrencies']); - - Route::apiResource('exchange-rate-providers', ExchangeRateProviderController::class); - - // Settings - // ---------------------------------- - - Route::get('/me', [UserProfileController::class, 'show']); - - Route::put('/me', [UserProfileController::class, 'update']); - - Route::get('/me/settings', [UserProfileController::class, 'showSettings']); - - Route::put('/me/settings', [UserProfileController::class, 'updateSettings']); - - Route::post('/me/upload-avatar', [UserProfileController::class, 'uploadAvatar']); - - Route::put('/company', [CompanyController::class, 'updateCompany']); - - Route::post('/company/upload-logo', [CompanyController::class, 'uploadCompanyLogo']); - - Route::get('/company/settings', [CompanySettingsController::class, 'show']); - - Route::post('/company/settings', [CompanySettingsController::class, 'update']); - - Route::get('/settings', [SettingsController::class, 'show']); - - Route::post('/settings', [SettingsController::class, 'update']); - - Route::get('/company/has-transactions', [CompanySettingsController::class, 'checkTransactions']); + require app_path('Platform/Operations/routes/settings.php'); // Mails // ---------------------------------- - Route::get('/mail/drivers', [MailConfigurationController::class, 'getMailDrivers']); + require app_path('Platform/Mail/routes/company.php'); - Route::get('/mail/config', [MailConfigurationController::class, 'getMailEnvironment']); - - Route::post('/mail/config', [MailConfigurationController::class, 'saveMailEnvironment']); - - Route::post('/mail/test', [MailConfigurationController::class, 'testEmailConfig']); - - Route::get('/company/mail/config', [CompanyMailConfigurationController::class, 'getDefaultConfig']); - - Route::get('/company/mail/company-config', [CompanyMailConfigurationController::class, 'getMailConfig']); - Route::post('/company/mail/company-config', [CompanyMailConfigurationController::class, 'saveMailConfig']); - Route::post('/company/mail/company-test', [CompanyMailConfigurationController::class, 'testMailConfig']); - - // AI Configuration - // ---------------------------------- - - Route::get('/ai/drivers', [AiConfigurationController::class, 'getDrivers']); - Route::get('/ai/config', [AiConfigurationController::class, 'getConfig']); - Route::post('/ai/config', [AiConfigurationController::class, 'saveConfig']); - Route::post('/ai/test', [AiConfigurationController::class, 'testConnection']); - - Route::get('/company/ai/config', [CompanyAiConfigurationController::class, 'getConfig']); - Route::post('/company/ai/config', [CompanyAiConfigurationController::class, 'saveConfig']); - Route::post('/company/ai/test', [CompanyAiConfigurationController::class, 'testConnection']); - - // AI Chat + text generation — rate-limited via the 'ai' limiter defined in RouteServiceProvider - Route::middleware('throttle:ai')->group(function () { - Route::post('/ai/chat', AiChatController::class); - Route::get('/ai/conversations', [AiConversationController::class, 'index']); - Route::get('/ai/conversations/{id}', [AiConversationController::class, 'show']); - Route::patch('/ai/conversations/{id}', [AiConversationController::class, 'update']); - Route::delete('/ai/conversations/{id}', [AiConversationController::class, 'destroy']); - - Route::post('/ai/generate', AiGenerationController::class); - }); - - // PDF Generation - // ---------------------------------- - - Route::get('/pdf/drivers', [PDFConfigurationController::class, 'getDrivers']); - - Route::get('/pdf/config', [PDFConfigurationController::class, 'getEnvironment']); - - Route::post('/pdf/config', [PDFConfigurationController::class, 'saveEnvironment']); - - Route::apiResource('notes', NotesController::class); + require app_path('Platform/Ai/routes/company.php'); // Tax Types // ---------------------------------- - Route::apiResource('tax-types', TaxTypesController::class); + require app_path('Domains/Taxation/routes/company.php'); - // Roles - // ---------------------------------- - - Route::get('abilities', AbilitiesController::class); - - Route::apiResource('roles', RolesController::class); }); // Self Update @@ -488,59 +134,10 @@ Route::prefix('/v1')->group(function () { // Disabled inside the official Docker image — containers upgrade via // `docker compose pull`, not the in-app updater (see EnsureNotContainerized). - Route::middleware('not-containerized')->group(function () { - Route::get('/check/update', [UpdateController::class, 'checkVersion']); - Route::post('/update/download', [UpdateController::class, 'download']); - Route::post('/update/unzip', [UpdateController::class, 'unzip']); - Route::post('/update/copy', [UpdateController::class, 'copy']); - Route::post('/update/delete', [UpdateController::class, 'delete']); - Route::post('/update/clean', [UpdateController::class, 'clean']); - Route::post('/update/migrate', [UpdateController::class, 'migrate']); - Route::post('/update/finish', [UpdateController::class, 'finish']); - }); + require app_path('Platform/Operations/routes/updater.php'); - // Companies - // ------------------------------------------------- + require app_path('Domains/Accounts/routes/management.php'); - Route::post('companies', [CompaniesController::class, 'store']); - - Route::post('/transfer/ownership/{user}', [CompanySettingsController::class, 'transferOwnership']); - - Route::post('companies/delete', [CompaniesController::class, 'destroy']); - - Route::get('companies', [CompaniesController::class, 'userCompanies']); - - // Users - // ---------------------------------- - - Route::post('/members/delete', [MembersController::class, 'delete']); - - Route::apiResource('/members', MembersController::class); - - // Modules - // ---------------------------------- - - Route::prefix('/modules')->group(function () { - Route::get('/', [ModulesController::class, 'index']); - Route::get('/pairing', [MarketplacePairingController::class, 'status']); - Route::post('/pairing/start', [MarketplacePairingController::class, 'start']); - Route::post('/pairing/poll', [MarketplacePairingController::class, 'poll']); - Route::delete('/pairing', [MarketplacePairingController::class, 'disconnect']); - Route::get('/{module}', [ModulesController::class, 'show']); - Route::post('/{module}/enable', [ModulesController::class, 'enable']); - Route::post('/{module}/disable', [ModulesController::class, 'disable']); - Route::post('/{module}/uninstall', [ModuleInstallationController::class, 'uninstall']); - - Route::post('/install', [ModuleInstallationController::class, 'install']); - - // Per-slug settings (schema-driven, per-company storage) - Route::get('/{slug}/settings', [ModuleSettingsController::class, 'show']); - Route::put('/{slug}/settings', [ModuleSettingsController::class, 'update']); - }); - - // Company-context Active Modules index (read-only, lists every - // instance-activated module with a has_settings flag) - Route::get('/company-modules', [CompanyModulesController::class, 'index']); }); Route::prefix('/{company:slug}/customer')->group(function () { @@ -548,50 +145,22 @@ Route::prefix('/v1')->group(function () { // Authentication & Password Reset // ---------------------------------- - Route::prefix('auth')->group(function () { - - // Send reset password mail - Route::post('password/email', [AuthForgotPasswordController::class, 'sendResetLinkEmail']); - - // handle reset password form process - Route::post('reset/password', [AuthResetPasswordController::class, 'reset'])->name('customer.password.reset'); - }); + require app_path('Domains/Contacts/routes/customer-public.php'); // Invoices, Estimates, Payments and Expenses endpoints // ------------------------------------------------------- Route::middleware(['auth:customer', 'customer-portal'])->group(function () { - Route::get('/bootstrap', CustomerBootstrapController::class); + require app_path('Domains/Contacts/routes/customer.php'); - Route::get('/dashboard', CustomerDashboardController::class); + require app_path('Domains/Sales/routes/customer.php'); - Route::get('invoices', [CustomerInvoicesController::class, 'index']); + require app_path('Domains/Receivables/routes/customer.php'); - Route::get('invoices/{id}', [CustomerInvoicesController::class, 'show']); + require app_path('Domains/Purchases/routes/customer.php'); - Route::post('/estimate/{estimate}/status', CustomerAcceptEstimateController::class); - - Route::get('estimates', [CustomerEstimatesController::class, 'index']); - - Route::get('estimates/{id}', [CustomerEstimatesController::class, 'show']); - - Route::get('payments', [CustomerPaymentsController::class, 'index']); - - Route::get('payments/{id}', [CustomerPaymentsController::class, 'show']); - - Route::get('/payment-method', PaymentMethodController::class); - - Route::get('expenses', [CustomerExpensesController::class, 'index']); - - Route::get('expenses/{id}', [CustomerExpensesController::class, 'show']); - - Route::post('/profile', [CustomerProfileController::class, 'updateProfile']); - - Route::get('/me', [CustomerProfileController::class, 'getUser']); - - Route::get('/countries', CountriesController::class); }); }); }); -Route::get('/cron', CronJobController::class)->middleware('cron-job'); +require app_path('Platform/Operations/routes/webhooks.php'); diff --git a/routes/console.php b/routes/console.php index f7571995..ab83c4bc 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,9 +1,9 @@ withoutOverlapping(); } -if (InstallUtils::isDbCreated()) { +if (InstallationState::isDbCreated()) { Schedule::command('check:invoices:status') ->daily(); diff --git a/routes/web.php b/routes/web.php index 79b02648..730c3f4b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,147 +1,40 @@ logout(); - - request()->session()->invalidate(); - request()->session()->regenerateToken(); -}); - -// Customer auth -// ---------------------------------------------- - -Route::post('/{company:slug}/customer/login', CustomerLoginController::class); - -Route::post('/{company:slug}/customer/logout', function () { - Auth::guard('customer')->logout(); -}); +require app_path('Domains/Accounts/routes/web.php'); +require app_path('Domains/Contacts/routes/web.php'); // Report PDF & Expense Endpoints // ---------------------------------------------- Route::middleware('auth:sanctum')->prefix('reports')->group(function () { + require app_path('Domains/Reporting/routes/web.php'); - Route::get('/customers/{customer}/statement', CustomerStatementReportController::class); - - // sales report by customer - // ---------------------------------- - Route::get('/sales/customers/{hash}', CustomerSalesReportController::class); - - // sales report by items - // ---------------------------------- - Route::get('/sales/items/{hash}', ItemSalesReportController::class); - - // report for expenses - // ---------------------------------- - Route::get('/expenses/{hash}', ExpensesReportController::class); - - // report for tax summary - // ---------------------------------- - Route::get('/tax-summary/{hash}', TaxSummaryReportController::class); - - // report for profit and loss - // ---------------------------------- - Route::get('/profit-loss/{hash}', ProfitLossReportController::class); - - // download expense receipt - // ------------------------------------------------- - Route::get('/expenses/{expense}/download-receipt', [ExpensesController::class, 'downloadReceipt']); - Route::get('/expenses/{expense}/receipt', [ExpensesController::class, 'showReceipt']); + require app_path('Domains/Purchases/routes/web.php'); }); // PDF Endpoints // ---------------------------------------------- -// Invitation email link handlers -// ------------------------------------------------- - -Route::get('/invitations/{token}/decline', function (string $token) { - $invitation = CompanyInvitation::where('token', $token)->pending()->first(); - - if (! $invitation) { - return view('app')->with(['message' => 'Invitation not found or already expired.']); - } - - $invitation->update(['status' => CompanyInvitation::STATUS_DECLINED]); - - return view('app')->with(['message' => 'Invitation declined.']); -}); - Route::middleware('pdf-auth')->group(function () { - - // invoice pdf - // ------------------------------------------------- - Route::get('/invoices/pdf/{invoice:unique_hash}', [DocumentPdfController::class, 'invoice']); - Route::get('/estimates/pdf/{estimate:unique_hash}', [DocumentPdfController::class, 'estimate']); - Route::get('/payments/pdf/{payment:unique_hash}', [DocumentPdfController::class, 'payment']); + require app_path('Domains/Sales/routes/pdf.php'); + require app_path('Domains/Receivables/routes/pdf.php'); }); // customer pdf endpoints for invoice, estimate and Payment // ------------------------------------------------- Route::prefix('/customer')->group(function () { - Route::get('/invoices/{email_log:token}', [CustomerInvoicePdfController::class, 'getInvoice']); - Route::get('/invoices/view/{email_log:token}', [CustomerInvoicePdfController::class, 'getPdf'])->name('invoice'); - - Route::get('/estimates/{email_log:token}', [CustomerEstimatePdfController::class, 'getEstimate']); - Route::get('/estimates/view/{email_log:token}', [CustomerEstimatePdfController::class, 'getPdf'])->name('estimate'); - - Route::get('/payments/{email_log:token}', [CustomerPaymentPdfController::class, 'getPayment']); - Route::get('/payments/view/{email_log:token}', [CustomerPaymentPdfController::class, 'getPdf'])->name('payment'); + require app_path('Domains/Sales/routes/public.php'); + require app_path('Domains/Receivables/routes/public.php'); }); // Setup for installation of app // ---------------------------------------------- -Route::get('/installation', function () { - return view('app'); -})->name('install') - ->middleware(['redirect-if-installed']); - -// Catch-all for installation wizard sub-routes (language, requirements, -// permissions, database, domain, mail, account, company, preferences). -// The Vue Router handles the actual step rendering on the SPA side; this -// just makes sure deep links and hard refreshes inside the wizard hit the -// SPA shell instead of 404ing. -Route::get('/installation/{vue?}', function () { - return view('app'); -})->where('vue', '.*') - ->middleware(['redirect-if-installed']); - -Route::post('/installation/session-login', SessionLoginController::class) - ->middleware(['redirect-if-installed', 'auth:sanctum']); +require app_path('Platform/Operations/Installation/routes/web.php'); // Registration via invitation (serves SPA) // ------------------------------------------------- diff --git a/tests/Feature/Admin/AdminSettingsTest.php b/tests/Feature/Admin/AdminSettingsTest.php index 9f997616..7880a184 100644 --- a/tests/Feature/Admin/AdminSettingsTest.php +++ b/tests/Feature/Admin/AdminSettingsTest.php @@ -1,6 +1,6 @@ getProviders(AccountsServiceProvider::class))->toHaveCount(1) + ->and(app(CompanyAddressWriter::class))->toBeInstanceOf(EloquentCompanyAddressWriter::class) + ->and(app(CompanyDataPurger::class))->toBeInstanceOf(EloquentCompanyDataPurger::class) + ->and(app(CompanyDefaultsProvisioner::class))->toBeInstanceOf(EloquentBusinessDefaultsProvisioner::class) + ->and(app(CompanyInvitationSender::class))->toBeInstanceOf(LaravelCompanyInvitationSender::class) + ->and(app(CompanyLogoManager::class))->toBeInstanceOf(MediaLibraryCompanyLogoManager::class) + ->and(app(MemberReferencesCleaner::class))->toBeInstanceOf(EloquentMemberReferencesCleaner::class) + ->and(app(UserAvatarManager::class))->toBeInstanceOf(MediaLibraryUserAvatarManager::class) + ->and(Gate::getPolicyFor(Company::class))->toBeInstanceOf(CompanyPolicy::class) + ->and(Gate::getPolicyFor(User::class))->toBeInstanceOf(UserPolicy::class) + ->and(Gate::getPolicyFor(Role::class))->toBeInstanceOf(RolePolicy::class) + ->and(Gate::has('create company'))->toBeTrue() + ->and(Gate::has('transfer company ownership'))->toBeTrue() + ->and(Gate::has('delete company'))->toBeTrue() + ->and(Gate::has('manage company'))->toBeTrue() + ->and(Gate::has('delete multiple users'))->toBeTrue() + ->and(Gate::has('owner only'))->toBeTrue(); + + expect(class_exists('App\\Services\\Company\\CompanyService'))->toBeFalse() + ->and(class_exists('App\\Services\\Company\\InvitationService'))->toBeFalse() + ->and(class_exists('App\\Services\\Company\\MemberService'))->toBeFalse() + ->and(class_exists('App\\Policies\\CompanyPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\UserPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\RolePolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\SettingsPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\OwnerPolicy'))->toBeFalse() + ->and(class_exists('App\\Mail\\CompanyInvitationMail'))->toBeFalse() + ->and(class_exists('App\\Notifications\\MailResetPasswordNotification'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\CompaniesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\UsersController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Members\\MembersController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Role\\RolesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Settings\\CompanyController'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CompanyCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CompanyResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\UserResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\RoleCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\UserCollection'))->toBeFalse(); +}); + +test('account middleware aliases resolve to the accounts domain', function () { + $middleware = app('router')->getMiddleware(); + + expect($middleware['auth'])->toBe(Authenticate::class) + ->and($middleware['company'])->toBe(CompanyMiddleware::class) + ->and($middleware['guest'])->toBe(RedirectIfAuthenticated::class) + ->and($middleware['redirect-if-unauthenticated'])->toBe(RedirectIfUnauthorized::class) + ->and($middleware['bouncer'])->toBe(ScopeBouncer::class) + ->and($middleware['super-admin'])->toBe(SuperAdminMiddleware::class); +}); + +test('the accounts domain preserves public and super-admin routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()); + $publicUris = [ + 'api/v1/auth/login', + 'api/v1/auth/logout', + 'api/v1/auth/password/email', + 'api/v1/auth/register-with-invitation', + 'api/v1/auth/reset/password', + 'api/v1/invitations/{token}/details', + 'login', + 'auth/logout', + 'invitations/{token}/decline', + ]; + $publicRoutes = $routes + ->filter(fn ($route): bool => in_array($route->uri(), $publicUris, true)) + ->filter(fn ($route): bool => str_starts_with($route->getActionName(), 'App\\Domains\\Accounts\\Http\\Controllers\\')); + + expect($publicRoutes)->toHaveCount(count($publicUris)); + + foreach ($publicRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Accounts\\Http\\Controllers\\'); + } + + $adminRoutes = $routes + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/super-admin/')) + ->reject(fn ($route): bool => $route->uri() === 'api/v1/super-admin/dashboard'); + + expect($adminRoutes)->toHaveCount(8); + + foreach ($adminRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Accounts\\Http\\Controllers\\Admin\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum'); + + if ($route->uri() !== 'api/v1/super-admin/stop-impersonating') { + expect($route->gatherMiddleware())->toContain('super-admin'); + } + } +}); + +test('the accounts domain preserves company account routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->getActionName(), 'App\\Domains\\Accounts\\Http\\Controllers\\')) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/')) + ->reject(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/auth/')) + ->reject(fn ($route): bool => $route->uri() === 'api/v1/invitations/{token}/details') + ->reject(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/super-admin/')); + + expect($routes)->toHaveCount(32); + + foreach ($routes as $route) { + expect($route->gatherMiddleware())->toContain('auth:sanctum', 'company'); + + if (! preg_match('#^api/v1/(?:companies|members|transfer/)#', $route->uri())) { + expect($route->gatherMiddleware())->toContain('bouncer'); + } + } +}); diff --git a/tests/Feature/Architecture/AiPlatformBoundaryTest.php b/tests/Feature/Architecture/AiPlatformBoundaryTest.php new file mode 100644 index 00000000..309a6b24 --- /dev/null +++ b/tests/Feature/Architecture/AiPlatformBoundaryTest.php @@ -0,0 +1,56 @@ +getProviders(AiServiceProvider::class))->toHaveCount(1) + ->and(app(AiToolRegistry::class))->toBe(app(AiToolRegistry::class)) + ->and(Gate::getPolicyFor(AiConversation::class))->toBeInstanceOf(AiConversationPolicy::class) + ->and(Gate::has('manage ai config'))->toBeTrue() + ->and(Gate::has('use ai'))->toBeTrue() + ->and(RateLimiter::limiter('ai'))->not->toBeNull() + ->and(Registry::driverMeta('ai', 'openrouter')['class'])->toBe(OpenRouterDriver::class); + + expect(class_exists('App\\Providers\\AiServiceProvider'))->toBeFalse() + ->and(class_exists('App\\Services\\Ai\\AiAssistantService'))->toBeFalse() + ->and(class_exists('App\\Support\\Ai\\AiDriver'))->toBeFalse() + ->and(class_exists('App\\Policies\\AiConversationPolicy'))->toBeFalse(); +}); + +test('the ai platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_contains($route->uri(), '/ai/')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/ai/conversations/{id}', + 'GET|HEAD api/v1/ai/config', + 'GET|HEAD api/v1/ai/conversations', + 'GET|HEAD api/v1/ai/conversations/{id}', + 'GET|HEAD api/v1/ai/drivers', + 'GET|HEAD api/v1/company/ai/config', + 'GET|HEAD api/v1/installation/ai/config', + 'PATCH api/v1/ai/conversations/{id}', + 'POST api/v1/ai/chat', + 'POST api/v1/ai/config', + 'POST api/v1/ai/generate', + 'POST api/v1/ai/test', + 'POST api/v1/company/ai/config', + 'POST api/v1/company/ai/test', + 'POST api/v1/installation/ai/config', + ])->sort()->values()->all()); + + $chat = $routes->get('POST api/v1/ai/chat'); + + expect($chat->getActionName())->toBe(ChatController::class) + ->and($chat->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer', 'throttle:ai'); +}); diff --git a/tests/Feature/Architecture/CatalogDomainBoundaryTest.php b/tests/Feature/Architecture/CatalogDomainBoundaryTest.php new file mode 100644 index 00000000..9448aeec --- /dev/null +++ b/tests/Feature/Architecture/CatalogDomainBoundaryTest.php @@ -0,0 +1,62 @@ +getProviders(CatalogServiceProvider::class))->toHaveCount(1) + ->and(app(ItemTaxManager::class))->toBeInstanceOf(TaxationItemTaxManager::class) + ->and(Gate::getPolicyFor(Item::class))->toBeInstanceOf(ItemPolicy::class) + ->and(Gate::getPolicyFor(Unit::class))->toBeInstanceOf(UnitPolicy::class) + ->and(Gate::has('delete multiple items'))->toBeTrue(); + + expect(class_exists('App\\Services\\ItemService'))->toBeFalse() + ->and(class_exists('App\\Policies\\ItemPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\UnitPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Item\\ItemsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Item\\UnitsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ItemsRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\DeleteItemsRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\UnitRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ItemResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\UnitResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ItemCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\UnitCollection'))->toBeFalse(); +}); + +test('the catalog domain preserves item and unit routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:items|units)(?:$|/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/items/{item}', + 'DELETE api/v1/units/{unit}', + 'GET|HEAD api/v1/items', + 'GET|HEAD api/v1/items/create', + 'GET|HEAD api/v1/items/{item}', + 'GET|HEAD api/v1/items/{item}/edit', + 'GET|HEAD api/v1/units', + 'GET|HEAD api/v1/units/create', + 'GET|HEAD api/v1/units/{unit}', + 'GET|HEAD api/v1/units/{unit}/edit', + 'POST api/v1/items', + 'POST api/v1/items/delete', + 'POST api/v1/units', + 'PUT|PATCH api/v1/items/{item}', + 'PUT|PATCH api/v1/units/{unit}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Catalog\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Architecture/ContactsDomainBoundaryTest.php b/tests/Feature/Architecture/ContactsDomainBoundaryTest.php new file mode 100644 index 00000000..882eb972 --- /dev/null +++ b/tests/Feature/Architecture/ContactsDomainBoundaryTest.php @@ -0,0 +1,111 @@ +getProviders(ContactsServiceProvider::class))->toHaveCount(1) + ->and(app(CustomerAvatarManager::class))->toBeInstanceOf(MediaLibraryCustomerAvatarManager::class) + ->and(app(CustomerDataPurger::class))->toBeInstanceOf(EloquentCustomerDataPurger::class) + ->and(app(CustomerPortalDashboardProvider::class))->toBeInstanceOf(EloquentCustomerPortalDashboardProvider::class) + ->and(app(CustomerStatsProvider::class))->toBeInstanceOf(EloquentCustomerStatsProvider::class) + ->and(Gate::getPolicyFor(Customer::class))->toBeInstanceOf(CustomerPolicy::class) + ->and(Gate::has('delete multiple customers'))->toBeTrue(); + + foreach ([ + 'App\\Services\\CustomerService', + 'App\\Policies\\CustomerPolicy', + 'App\\Notifications\\CustomerMailResetPasswordNotification', + 'App\\Http\\Controllers\\Admin\\CountriesController', + 'App\\Http\\Controllers\\Company\\Customer\\CustomersController', + 'App\\Http\\Controllers\\Company\\Customer\\CustomerStatsController', + 'App\\Http\\Controllers\\CustomerPortal\\Auth\\ForgotPasswordController', + 'App\\Http\\Controllers\\CustomerPortal\\Auth\\LoginController', + 'App\\Http\\Controllers\\CustomerPortal\\Auth\\ResetPasswordController', + 'App\\Http\\Controllers\\CustomerPortal\\General\\BootstrapController', + 'App\\Http\\Controllers\\CustomerPortal\\General\\DashboardController', + 'App\\Http\\Controllers\\CustomerPortal\\General\\ProfileController', + 'App\\Http\\Requests\\CustomerRequest', + 'App\\Http\\Requests\\DeleteCustomersRequest', + 'App\\Http\\Requests\\Customer\\CustomerLoginRequest', + 'App\\Http\\Requests\\Customer\\CustomerProfileRequest', + 'App\\Http\\Resources\\AddressResource', + 'App\\Http\\Resources\\CountryResource', + 'App\\Http\\Resources\\CustomerResource', + 'App\\Http\\Resources\\Customer\\AddressResource', + 'App\\Http\\Resources\\Customer\\CountryResource', + 'App\\Http\\Resources\\Customer\\CustomerResource', + 'App\\Http\\Resources\\AddressCollection', + 'App\\Http\\Resources\\CountryCollection', + 'App\\Http\\Resources\\CustomerCollection', + ] as $legacyClass) { + expect(class_exists($legacyClass))->toBeFalse(); + } +}); + +test('customer middleware aliases resolve to the contacts domain', function () { + $middleware = app('router')->getMiddleware(); + + expect($middleware['customer'])->toBe(CustomerRedirectIfAuthenticated::class) + ->and($middleware['customer-guest'])->toBe(CustomerGuest::class) + ->and($middleware['customer-portal'])->toBe(CustomerPortalMiddleware::class); +}); + +test('the contacts domain preserves public and company customer routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()); + + $countryRoutes = $routes->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/countries', + 'api/v1/{company}/customer/countries', + ], true)); + + expect($countryRoutes)->toHaveCount(2); + + foreach ($countryRoutes as $route) { + expect($route->getActionName())->toBe('App\\Domains\\Contacts\\Http\\Controllers\\CountriesController'); + } + + $companyRoutes = $routes + ->filter(fn ($route): bool => preg_match('#^api/v1/customers(?:$|/)#', $route->uri()) === 1) + ->reject(fn ($route): bool => str_contains($route->uri(), '/statement') || str_contains($route->uri(), '/credit-allocations')); + + expect($companyRoutes)->toHaveCount(9); + + foreach ($companyRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Contacts\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the contacts domain preserves customer portal identity routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()); + $portalRoutes = $routes + ->filter(fn ($route): bool => str_starts_with($route->getActionName(), 'App\\Domains\\Contacts\\Http\\Controllers\\CustomerPortal\\')); + + expect($portalRoutes)->toHaveCount(8); + + $authenticatedRoutes = $portalRoutes + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/')) + ->reject(fn ($route): bool => str_contains($route->uri(), '/auth/')); + + expect($authenticatedRoutes)->toHaveCount(4); + + foreach ($authenticatedRoutes as $route) { + expect($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } +}); diff --git a/tests/Feature/Architecture/MailPlatformBoundaryTest.php b/tests/Feature/Architecture/MailPlatformBoundaryTest.php new file mode 100644 index 00000000..72fcf1cc --- /dev/null +++ b/tests/Feature/Architecture/MailPlatformBoundaryTest.php @@ -0,0 +1,51 @@ +getProviders(MailServiceProvider::class))->toHaveCount(1) + ->and(app(MailConfigurator::class))->toBeInstanceOf(MailConfigurationService::class) + ->and(app(EmailLogWriter::class))->toBeInstanceOf(EloquentEmailLogWriter::class) + ->and(Gate::has('manage email config'))->toBeTrue(); + + expect(class_exists('App\\Services\\Mail\\MailConfigurationService'))->toBeFalse() + ->and(class_exists('App\\Services\\Mail\\CompanyMailConfigService'))->toBeFalse() + ->and(class_exists('App\\Mail\\TestMail'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\MailConfigurationController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Settings\\CompanyMailConfigurationController'))->toBeFalse(); +}); + +test('the mail platform preserves its public configuration routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:company/)?mail/#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/company/mail/company-config', + 'GET|HEAD api/v1/company/mail/config', + 'GET|HEAD api/v1/mail/config', + 'GET|HEAD api/v1/mail/drivers', + 'POST api/v1/company/mail/company-config', + 'POST api/v1/company/mail/company-test', + 'POST api/v1/mail/config', + 'POST api/v1/mail/test', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Mail\\Http\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + expect($routes->get('GET|HEAD api/v1/mail/config')->getActionName()) + ->toBe(MailConfigurationController::class.'@getMailEnvironment') + ->and($routes->get('GET|HEAD api/v1/company/mail/config')->getActionName()) + ->toBe(CompanyMailConfigurationController::class.'@getDefaultConfig'); +}); diff --git a/tests/Feature/Architecture/MetadataDomainBoundaryTest.php b/tests/Feature/Architecture/MetadataDomainBoundaryTest.php new file mode 100644 index 00000000..4471cd97 --- /dev/null +++ b/tests/Feature/Architecture/MetadataDomainBoundaryTest.php @@ -0,0 +1,64 @@ +getProviders(MetadataServiceProvider::class))->toHaveCount(1) + ->and(app(CustomFieldValueWriter::class))->toBeInstanceOf(EloquentCustomFieldValueWriter::class) + ->and(Gate::getPolicyFor(CustomField::class))->toBeInstanceOf(CustomFieldPolicy::class) + ->and(Gate::getPolicyFor(Note::class))->toBeInstanceOf(NotePolicy::class) + ->and(Gate::has('manage notes'))->toBeTrue() + ->and(Gate::has('view notes'))->toBeTrue(); + + expect(class_exists('App\\Services\\CustomFieldService'))->toBeFalse() + ->and(trait_exists('App\\Traits\\HasCustomFieldsTrait'))->toBeFalse() + ->and(class_exists('App\\Policies\\CustomFieldPolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\NotePolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\CustomField\\CustomFieldsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\NotesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\CustomFieldRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\NotesRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldValueResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CustomFieldResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CustomFieldValueResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\NoteResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomFieldValueCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\NoteCollection'))->toBeFalse(); +}); + +test('the metadata domain preserves custom field and note routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:custom-fields|notes)(?:$|/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/custom-fields/{custom_field}', + 'DELETE api/v1/notes/{note}', + 'GET|HEAD api/v1/custom-fields', + 'GET|HEAD api/v1/custom-fields/create', + 'GET|HEAD api/v1/custom-fields/{custom_field}', + 'GET|HEAD api/v1/custom-fields/{custom_field}/edit', + 'GET|HEAD api/v1/notes', + 'GET|HEAD api/v1/notes/{note}', + 'POST api/v1/custom-fields', + 'POST api/v1/notes', + 'PUT|PATCH api/v1/custom-fields/{custom_field}', + 'PUT|PATCH api/v1/notes/{note}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Metadata\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Architecture/ModelTypeAliasMigrationTest.php b/tests/Feature/Architecture/ModelTypeAliasMigrationTest.php new file mode 100644 index 00000000..d957a79d --- /dev/null +++ b/tests/Feature/Architecture/ModelTypeAliasMigrationTest.php @@ -0,0 +1,84 @@ +toBe([ + 'media' => ['model_type'], + 'email_logs' => ['mailable_type'], + 'notifications' => ['notifiable_type'], + 'personal_access_tokens' => ['tokenable_type'], + 'custom_field_values' => ['custom_field_valuable_type'], + 'abilities' => ['entity_type'], + 'assigned_roles' => ['entity_type', 'restricted_to_type'], + 'permissions' => ['entity_type'], + ]); +}); + +test('legacy model types migrate to stable aliases and can be rolled back', function () { + $abilityId = DB::table('abilities')->insertGetId([ + 'name' => 'architecture-migration-test', + 'entity_type' => 'InvoiceShelf\\Models\\Invoice', + 'only_owned' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_08_05_120000_stabilize_model_type_aliases.php'); + $migration->up(); + + expect(DB::table('abilities')->where('id', $abilityId)->value('entity_type'))->toBe('invoice'); + + $migration->down(); + + expect(DB::table('abilities')->where('id', $abilityId)->value('entity_type'))->toBe('App\\Models\\Invoice'); +}); + +test('legacy bouncer role identities migrate and can be rolled back', function () { + $roleId = DB::table('roles')->insertGetId([ + 'name' => 'architecture-migration-role', + 'created_at' => now(), + 'updated_at' => now(), + ]); + $abilityId = DB::table('abilities')->insertGetId([ + 'name' => 'architecture-migration-ability', + 'only_owned' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $permissionId = DB::table('permissions')->insertGetId([ + 'ability_id' => $abilityId, + 'entity_id' => $roleId, + 'entity_type' => 'roles', + 'forbidden' => false, + ]); + + $migration = require database_path('migrations/2026_08_05_120000_stabilize_model_type_aliases.php'); + $migration->up(); + + expect(DB::table('permissions')->where('id', $permissionId)->value('entity_type')) + ->toBe('bouncer_role'); + + $migration->down(); + + expect(DB::table('permissions')->where('id', $permissionId)->value('entity_type')) + ->toBe('roles'); +}); + +test('unknown model identities are left untouched', function () { + $abilityId = DB::table('abilities')->insertGetId([ + 'name' => 'architecture-unknown-test', + 'entity_type' => 'Modules\\Example\\Models\\Record', + 'only_owned' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $migration = require database_path('migrations/2026_08_05_120000_stabilize_model_type_aliases.php'); + $migration->up(); + + expect(DB::table('abilities')->where('id', $abilityId)->value('entity_type')) + ->toBe('Modules\\Example\\Models\\Record'); +}); diff --git a/tests/Feature/Architecture/ModulePlatformBoundaryTest.php b/tests/Feature/Architecture/ModulePlatformBoundaryTest.php new file mode 100644 index 00000000..b9746965 --- /dev/null +++ b/tests/Feature/Architecture/ModulePlatformBoundaryTest.php @@ -0,0 +1,62 @@ +getProviders(ModuleServiceProvider::class))->not->toBeEmpty() + ->and((new Module)->getTable())->toBe('modules') + ->and((new MarketplaceCredential)->getTable())->toBe('marketplace_credentials') + ->and((new MarketplaceOperation)->getTable())->toBe('marketplace_operations') + ->and(class_exists('App\\Models\\Module'))->toBeFalse() + ->and(class_exists('App\\Services\\Marketplace\\MarketplaceInstaller'))->toBeFalse(); +}); + +test('the module platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes())->filter( + fn (IlluminateRoute $route): bool => $route->uri() === 'api/v1/company-modules' + || str_starts_with($route->uri(), 'api/v1/modules') + || str_starts_with($route->uri(), 'modules/scripts') + || str_starts_with($route->uri(), 'modules/styles') + ); + + $signatures = $routes + ->flatMap(fn (IlluminateRoute $route): array => collect($route->methods()) + ->reject(fn (string $method): bool => $method === 'HEAD') + ->map(fn (string $method): string => "{$method} {$route->uri()}") + ->all()) + ->sort() + ->values() + ->all(); + + expect($signatures)->toBe(collect([ + 'GET api/v1/company-modules', + 'GET api/v1/modules', + 'GET api/v1/modules/pairing', + 'POST api/v1/modules/pairing/start', + 'POST api/v1/modules/pairing/poll', + 'DELETE api/v1/modules/pairing', + 'GET api/v1/modules/{module}', + 'POST api/v1/modules/{module}/enable', + 'POST api/v1/modules/{module}/disable', + 'POST api/v1/modules/{module}/uninstall', + 'POST api/v1/modules/install', + 'GET api/v1/modules/{slug}/settings', + 'PUT api/v1/modules/{slug}/settings', + 'GET modules/scripts/{script}', + 'GET modules/styles/{style}', + ])->sort()->values()->all()); + + $routes->each(function (IlluminateRoute $route): void { + $expected = str_starts_with($route->uri(), 'api/') + ? ['api', 'auth:sanctum', 'company'] + : ['web']; + + expect($route->middleware())->toBe($expected) + ->and($route->getActionName())->toStartWith('App\\Platform\\Modules\\'); + }); +}); diff --git a/tests/Feature/Architecture/MoneyDomainBoundaryTest.php b/tests/Feature/Architecture/MoneyDomainBoundaryTest.php new file mode 100644 index 00000000..22f4a9b1 --- /dev/null +++ b/tests/Feature/Architecture/MoneyDomainBoundaryTest.php @@ -0,0 +1,62 @@ +getProviders(MoneyServiceProvider::class))->toHaveCount(1) + ->and(app(ExchangeRateBackfill::class))->toBeInstanceOf(EloquentExchangeRateBackfill::class) + ->and(Gate::getPolicyFor(ExchangeRateProvider::class))->toBeInstanceOf(ExchangeRateProviderPolicy::class) + ->and(Registry::driverMeta('exchange_rate', 'currency_converter')['class'] ?? null) + ->toBe(CurrencyConverterDriver::class) + ->and(Registry::driverMeta('exchange_rate', 'currency_freak')['class'] ?? null) + ->toBe(CurrencyFreakDriver::class); + + expect(class_exists('App\\Providers\\DriverRegistryProvider'))->toBeFalse() + ->and(class_exists('App\\Support\\ExchangeRate\\ExchangeRateDriverFactory'))->toBeFalse() + ->and(class_exists('App\\Services\\Document\\CurrencyService'))->toBeFalse() + ->and(class_exists('App\\Services\\ExchangeRateProviderService'))->toBeFalse() + ->and(class_exists('App\\Policies\\ExchangeRateProviderPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\CurrenciesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\ExchangeRate\\ExchangeRateProviderController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ExchangeRateProviderRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CurrencyResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\CurrencyResource'))->toBeFalse(); +}); + +test('the money domain preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/v1/(?:currencies(?:$|/)|exchange-rate-providers(?:$|/)|supported-currencies$|used-currencies$)#', + $route->uri(), + ) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/exchange-rate-providers/{exchange_rate_provider}', + 'GET|HEAD api/v1/currencies', + 'GET|HEAD api/v1/currencies/used', + 'GET|HEAD api/v1/currencies/{currency}/active-provider', + 'GET|HEAD api/v1/currencies/{currency}/exchange-rate', + 'GET|HEAD api/v1/exchange-rate-providers', + 'GET|HEAD api/v1/exchange-rate-providers/{exchange_rate_provider}', + 'GET|HEAD api/v1/supported-currencies', + 'GET|HEAD api/v1/used-currencies', + 'POST api/v1/currencies/bulk-update-exchange-rate', + 'POST api/v1/exchange-rate-providers', + 'PUT|PATCH api/v1/exchange-rate-providers/{exchange_rate_provider}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Money\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Architecture/OperationsPlatformBoundaryTest.php b/tests/Feature/Architecture/OperationsPlatformBoundaryTest.php new file mode 100644 index 00000000..39c2a462 --- /dev/null +++ b/tests/Feature/Architecture/OperationsPlatformBoundaryTest.php @@ -0,0 +1,152 @@ +getProviders(OperationsServiceProvider::class))->toHaveCount(1) + ->and(app(StorageConfigurator::class))->toBeInstanceOf(FileDiskService::class) + ->and(Gate::has('manage settings'))->toBeTrue() + ->and(Gate::has('manage update app'))->toBeTrue() + ->and(Artisan::all())->toHaveKeys(['core:update', 'reset:app']); + + expect(class_exists('App\\Providers\\AppConfigProvider'))->toBeFalse() + ->and(class_exists('App\\Support\\Update\\Updater'))->toBeFalse() + ->and(class_exists('App\\Console\\Commands\\UpdateCommand'))->toBeFalse() + ->and(class_exists('App\\Console\\Commands\\ResetApp'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\AppVersionController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\UpdateController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\SettingsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\AdminDashboardController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\BootstrapController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\ConfigController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\FormatsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Webhook\\CronJobController'))->toBeFalse() + ->and(class_exists('App\\Support\\Setup\\InstallUtils'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Setup\\LoginController'))->toBeFalse() + ->and(class_exists('App\\Http\\Middleware\\InstallationMiddleware'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\DatabaseEnvironmentRequest'))->toBeFalse(); +}); + +test('the operations platform owns bootstrap configuration and admin diagnostics routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/bootstrap', + 'api/v1/config', + 'api/v1/current-company', + 'api/v1/date/formats', + 'api/v1/super-admin/dashboard', + 'api/v1/time/formats', + 'api/v1/timezones', + ], true)) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/bootstrap', + 'GET|HEAD api/v1/config', + 'GET|HEAD api/v1/current-company', + 'GET|HEAD api/v1/date/formats', + 'GET|HEAD api/v1/super-admin/dashboard', + 'GET|HEAD api/v1/time/formats', + 'GET|HEAD api/v1/timezones', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Operations\\Http\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum'); + } + + expect($routes->get('GET|HEAD api/v1/super-admin/dashboard')->gatherMiddleware()) + ->toContain('super-admin') + ->and($routes->except('GET|HEAD api/v1/super-admin/dashboard')->every( + fn ($route): bool => in_array('company', $route->gatherMiddleware(), true) + && in_array('bouncer', $route->gatherMiddleware(), true), + ))->toBeTrue(); +}); + +test('the operations platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/(?:v1/(?:app/version|settings$|check/update$|update/)|cron$)#', + $route->uri(), + ) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/cron', + 'GET|HEAD api/v1/app/version', + 'GET|HEAD api/v1/check/update', + 'GET|HEAD api/v1/settings', + 'POST api/v1/settings', + 'POST api/v1/update/clean', + 'POST api/v1/update/copy', + 'POST api/v1/update/delete', + 'POST api/v1/update/download', + 'POST api/v1/update/finish', + 'POST api/v1/update/migrate', + 'POST api/v1/update/unzip', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Operations\\Http\\'); + } + + foreach (['GET|HEAD api/v1/settings', 'POST api/v1/settings'] as $key) { + expect($routes->get($key)->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + foreach ($routes->filter(fn ($route) => str_contains($route->uri(), 'update')) as $route) { + expect($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'not-containerized'); + } + + expect($routes->get('GET|HEAD api/cron')->gatherMiddleware())->toContain('cron-job') + ->and(app('router')->getMiddleware()['cron-job'] ?? null)->toBe(CronJobMiddleware::class) + ->and(app('router')->getMiddleware()['not-containerized'] ?? null)->toBe(EnsureNotContainerized::class); +}); + +test('the operations platform owns installation routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => ( + str_starts_with($route->uri(), 'api/v1/installation/') + && ! str_starts_with($route->uri(), 'api/v1/installation/ai/') + ) || str_starts_with($route->uri(), 'installation')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/installation/database/config', + 'GET|HEAD api/v1/installation/languages', + 'GET|HEAD api/v1/installation/permissions', + 'GET|HEAD api/v1/installation/requirements', + 'GET|HEAD api/v1/installation/wizard-step', + 'GET|HEAD installation', + 'GET|HEAD installation/{vue?}', + 'POST api/v1/installation/database/config', + 'POST api/v1/installation/finish', + 'POST api/v1/installation/login', + 'POST api/v1/installation/wizard-language', + 'POST api/v1/installation/wizard-step', + 'POST installation/session-login', + 'PUT api/v1/installation/set-domain', + ])->sort()->values()->all()); + + foreach ($routes->filter(fn ($route) => str_starts_with($route->uri(), 'api/')) as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Platform\\Operations\\Installation\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('redirect-if-installed'); + } + + expect($routes->get('POST installation/session-login')->getActionName()) + ->toBe('App\\Platform\\Operations\\Installation\\Http\\Controllers\\SessionLoginController') + ->and(app('router')->getMiddleware()['install'] ?? null)->toBe(EnsureInstalled::class) + ->and(app('router')->getMiddleware()['redirect-if-installed'] ?? null)->toBe(RedirectIfInstalled::class) + ->and(app()->make(UseInstallWizardTokenAuth::class))->toBeInstanceOf(UseInstallWizardTokenAuth::class); +}); diff --git a/tests/Feature/Architecture/PdfPlatformBoundaryTest.php b/tests/Feature/Architecture/PdfPlatformBoundaryTest.php new file mode 100644 index 00000000..54ff9e04 --- /dev/null +++ b/tests/Feature/Architecture/PdfPlatformBoundaryTest.php @@ -0,0 +1,56 @@ +getProviders(PdfServiceProvider::class))->toHaveCount(1) + ->and(app('pdf.driver'))->toBeInstanceOf(PdfService::class) + ->and(app(PdfConfigurator::class))->toBeInstanceOf(PdfConfigurationService::class) + ->and(Gate::has('manage pdf config'))->toBeTrue() + ->and(Artisan::all())->toHaveKeys(['make:template', 'pdf:compare']); + + expect(class_exists('App\\Providers\\PdfServiceProvider'))->toBeFalse() + ->and(class_exists('App\\Services\\FontService'))->toBeFalse() + ->and(class_exists('App\\Facades\\Pdf'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\FontController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\PDFConfigurationController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\PDFConfigurationRequest'))->toBeFalse() + ->and(class_exists('App\\Console\\Commands\\ComparePdfDriversCommand'))->toBeFalse() + ->and(trait_exists('App\\Traits\\GeneratesPdfTrait'))->toBeFalse() + ->and(is_dir(app_path('Support/Pdf')))->toBeFalse(); +}); + +test('the pdf platform preserves its public configuration routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:fonts/|pdf/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD api/v1/fonts/status', + 'GET|HEAD api/v1/pdf/config', + 'GET|HEAD api/v1/pdf/drivers', + 'POST api/v1/fonts/{package}/install', + 'POST api/v1/pdf/config', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Pdf\\Http\\Admin\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + expect($routes->get('GET|HEAD api/v1/fonts/status')->getActionName()) + ->toBe(FontController::class.'@status') + ->and($routes->get('GET|HEAD api/v1/pdf/config')->getActionName()) + ->toBe(PdfConfigurationController::class.'@getEnvironment') + ->and(app('router')->getMiddleware()['pdf-auth'] ?? null) + ->toBe(PdfMiddleware::class); +}); diff --git a/tests/Feature/Architecture/PurchasesDomainBoundaryTest.php b/tests/Feature/Architecture/PurchasesDomainBoundaryTest.php new file mode 100644 index 00000000..46369997 --- /dev/null +++ b/tests/Feature/Architecture/PurchasesDomainBoundaryTest.php @@ -0,0 +1,99 @@ +getProviders(PurchasesServiceProvider::class))->toHaveCount(1) + ->and(app(ExpenseTaxManager::class))->toBeInstanceOf(TaxationExpenseTaxManager::class) + ->and(app(ExpenseExchangeRateRecorder::class))->toBeInstanceOf(MoneyExpenseExchangeRateRecorder::class) + ->and(app(ExpenseReceiptManager::class))->toBeInstanceOf(MediaLibraryExpenseReceiptManager::class) + ->and(Gate::getPolicyFor(Expense::class))->toBeInstanceOf(ExpensePolicy::class) + ->and(Gate::getPolicyFor(ExpenseCategory::class))->toBeInstanceOf(ExpenseCategoryPolicy::class) + ->and(Gate::has('delete multiple expenses'))->toBeTrue(); + + expect(class_exists('App\\Services\\Document\\ExpenseService'))->toBeFalse() + ->and(class_exists('App\\Policies\\ExpensePolicy'))->toBeFalse() + ->and(class_exists('App\\Policies\\ExpenseCategoryPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Expense\\ExpensesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Expense\\ExpenseCategoriesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\CustomerPortal\\Expense\\ExpensesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ExpenseRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\ExpenseCategoryRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\DeleteExpensesRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\UploadExpenseReceiptRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseCategoryResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseCollection'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\ExpenseCategoryCollection'))->toBeFalse(); + + expect(class_exists('App\\Http\\Resources\\Customer\\ExpenseResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\ExpenseCategoryResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\ExpenseCollection'))->toBeFalse(); +}); + +test('the purchases domain preserves company expense routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:expenses|categories)(?:$|/)#', $route->uri()) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/categories/{category}', + 'DELETE api/v1/expenses/{expense}', + 'GET|HEAD api/v1/categories', + 'GET|HEAD api/v1/categories/{category}', + 'GET|HEAD api/v1/expenses', + 'GET|HEAD api/v1/expenses/{expense}', + 'GET|HEAD api/v1/expenses/{expense}/show/receipt', + 'POST api/v1/categories', + 'POST api/v1/expenses', + 'POST api/v1/expenses/delete', + 'POST api/v1/expenses/{expense}/upload/receipts', + 'PUT|PATCH api/v1/categories/{category}', + 'PUT|PATCH api/v1/expenses/{expense}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Purchases\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the purchases domain preserves customer and receipt web routes', function () { + $customerRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/expenses')); + + expect($customerRoutes)->toHaveCount(2); + + foreach ($customerRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Purchases\\Http\\Controllers\\CustomerPortal\\') + ->and($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } + + $receiptRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'reports/expenses/{expense}/download-receipt', + 'reports/expenses/{expense}/receipt', + ], true)); + + expect($receiptRoutes)->toHaveCount(2); + + foreach ($receiptRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Purchases\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum'); + } +}); diff --git a/tests/Feature/Architecture/ReceivablesDomainBoundaryTest.php b/tests/Feature/Architecture/ReceivablesDomainBoundaryTest.php new file mode 100644 index 00000000..4f45a3f7 --- /dev/null +++ b/tests/Feature/Architecture/ReceivablesDomainBoundaryTest.php @@ -0,0 +1,123 @@ +getProviders(ReceivablesServiceProvider::class))->toHaveCount(1) + ->and(app(PaymentPdfDataProvider::class))->toBeInstanceOf(PaymentService::class) + ->and(app(InvoiceBalanceUpdater::class))->toBeInstanceOf(SalesInvoiceBalanceUpdater::class) + ->and(app(PaymentNumberAssigner::class))->toBeInstanceOf(SalesPaymentNumberAssigner::class) + ->and(app(PaymentExchangeRateRecorder::class))->toBeInstanceOf(MoneyPaymentExchangeRateRecorder::class) + ->and(app(PaymentEmailSender::class))->toBeInstanceOf(LaravelPaymentEmailSender::class) + ->and(Gate::getPolicyFor(Payment::class))->toBeInstanceOf(PaymentPolicy::class) + ->and(Gate::getPolicyFor(PaymentMethod::class))->toBeInstanceOf(PaymentMethodPolicy::class) + ->and(Gate::has('send payment'))->toBeTrue() + ->and(Gate::has('delete multiple payments'))->toBeTrue(); + + foreach ([ + 'App\\Services\\Document\\PaymentService', + 'App\\Services\\Document\\PaymentAllocationService', + 'App\\Policies\\PaymentPolicy', + 'App\\Policies\\PaymentMethodPolicy', + 'App\\Jobs\\GeneratePaymentPdfJob', + 'App\\Mail\\SendPaymentMail', + 'App\\Http\\Controllers\\Company\\Payment\\PaymentsController', + 'App\\Http\\Controllers\\Company\\Payment\\PaymentMethodsController', + 'App\\Http\\Controllers\\Company\\Payment\\CreditAllocationsController', + 'App\\Http\\Controllers\\CustomerPortal\\Payment\\PaymentsController', + 'App\\Http\\Controllers\\CustomerPortal\\Payment\\PaymentMethodController', + 'App\\Http\\Controllers\\CustomerPortal\\PaymentPdfController', + 'App\\Http\\Requests\\PaymentRequest', + 'App\\Http\\Requests\\PaymentMethodRequest', + 'App\\Http\\Requests\\DeletePaymentsRequest', + 'App\\Http\\Requests\\ReplacePaymentAllocationsRequest', + 'App\\Http\\Requests\\CreditAllocationRequest', + 'App\\Http\\Requests\\SendPaymentRequest', + 'App\\Http\\Resources\\PaymentResource', + 'App\\Http\\Resources\\PaymentMethodResource', + 'App\\Http\\Resources\\PaymentCollection', + 'App\\Http\\Resources\\PaymentMethodCollection', + 'App\\Http\\Resources\\TransactionResource', + 'App\\Http\\Resources\\Customer\\PaymentResource', + 'App\\Http\\Resources\\Customer\\PaymentMethodResource', + 'App\\Http\\Resources\\Customer\\PaymentCollection', + 'App\\Http\\Resources\\Customer\\PaymentMethodCollection', + 'App\\Http\\Resources\\Customer\\TransactionResource', + ] as $legacyClass) { + expect(class_exists($legacyClass))->toBeFalse(); + } +}); + +test('the receivables domain preserves company payment routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match('#^api/v1/(?:payments|payment-methods)(?:$|/)#', $route->uri()) === 1 + || $route->uri() === 'api/v1/customers/{customer}/credit-allocations') + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/payment-methods/{payment_method}', + 'DELETE api/v1/payments/{payment}', + 'GET|HEAD api/v1/payment-methods', + 'GET|HEAD api/v1/payment-methods/{payment_method}', + 'GET|HEAD api/v1/payments', + 'GET|HEAD api/v1/payments/{payment}', + 'GET|HEAD api/v1/payments/{payment}/send/preview', + 'POST api/v1/customers/{customer}/credit-allocations', + 'POST api/v1/payment-methods', + 'POST api/v1/payments', + 'POST api/v1/payments/delete', + 'POST api/v1/payments/{payment}/send', + 'PUT api/v1/payments/{payment}/allocations', + 'PUT|PATCH api/v1/payment-methods/{payment_method}', + 'PUT|PATCH api/v1/payments/{payment}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Receivables\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the receivables domain preserves customer and pdf routes', function () { + $customerRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/payments') + || $route->uri() === 'api/v1/{company}/customer/payment-method'); + + expect($customerRoutes)->toHaveCount(3); + + foreach ($customerRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Receivables\\Http\\Controllers\\CustomerPortal\\') + ->and($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } + + $pdfRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'payments/pdf/{payment}', + 'customer/payments/{email_log}', + 'customer/payments/view/{email_log}', + ], true)); + + expect($pdfRoutes)->toHaveCount(3); + + foreach ($pdfRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Receivables\\Http\\Controllers\\'); + } +}); diff --git a/tests/Feature/Architecture/ReportingDomainBoundaryTest.php b/tests/Feature/Architecture/ReportingDomainBoundaryTest.php new file mode 100644 index 00000000..1757f1f4 --- /dev/null +++ b/tests/Feature/Architecture/ReportingDomainBoundaryTest.php @@ -0,0 +1,92 @@ +getProviders(ReportingServiceProvider::class))->toHaveCount(1) + ->and(app(CustomerStatementQuery::class))->toBeInstanceOf(CustomerStatementQuery::class) + ->and(app(CustomerStatementPdfRenderer::class))->toBeInstanceOf(CustomerStatementPdfRenderer::class) + ->and(Gate::has('view dashboard'))->toBeTrue() + ->and(Gate::has('view report'))->toBeTrue(); + + expect(class_exists('App\\Policies\\ReportPolicy'))->toBeFalse() + ->and(class_exists('App\\Services\\CustomerStatementService'))->toBeFalse() + ->and(class_exists('App\\Services\\CustomerStatementPdfService'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\CustomerStatementRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\SendCustomerStatementRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\CustomerStatementResource'))->toBeFalse() + ->and(class_exists('App\\Mail\\SendCustomerStatementMail'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Customer\\CustomerStatementController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Customer\\SendCustomerStatementController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Dashboard\\DashboardController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\General\\SearchController'))->toBeFalse() + ->and(class_exists('App\\Policies\\DashboardPolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\CustomerSalesReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\CustomerStatementReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\ExpensesReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\ItemSalesReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\ProfitLossReportController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Report\\TaxSummaryReportController'))->toBeFalse(); +}); + +test('the reporting domain owns dashboard and search projections', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/dashboard', + 'api/v1/search', + 'api/v1/search/user', + ], true)); + + expect($routes)->toHaveCount(3); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Reporting\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the reporting domain preserves customer statement api routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'api/v1/customers/{customer}/statement', + 'api/v1/customers/{customer}/statement/send', + ], true)); + + expect($routes)->toHaveCount(2); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Reporting\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the reporting domain preserves authenticated report routes', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^reports/(?:customers/[^/]+/statement|sales/(?:customers|items)/[^/]+|expenses/[^/]+|tax-summary/[^/]+|profit-loss/[^/]+)$#', + $route->uri(), + ) === 1) + ->reject(fn ($route): bool => str_contains($route->uri(), 'download-receipt') || str_contains($route->uri(), 'receipt')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'GET|HEAD reports/customers/{customer}/statement', + 'GET|HEAD reports/expenses/{hash}', + 'GET|HEAD reports/profit-loss/{hash}', + 'GET|HEAD reports/sales/customers/{hash}', + 'GET|HEAD reports/sales/items/{hash}', + 'GET|HEAD reports/tax-summary/{hash}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Reporting\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('web', 'auth:sanctum'); + } +}); diff --git a/tests/Feature/Architecture/SalesDomainBoundaryTest.php b/tests/Feature/Architecture/SalesDomainBoundaryTest.php new file mode 100644 index 00000000..0f50ff4f --- /dev/null +++ b/tests/Feature/Architecture/SalesDomainBoundaryTest.php @@ -0,0 +1,124 @@ +getProviders(SalesServiceProvider::class))->toHaveCount(1) + ->and(app(EstimatePdfDataProvider::class))->toBeInstanceOf(EstimateService::class) + ->and(app(InvoicePdfDataProvider::class))->toBeInstanceOf(InvoiceService::class) + ->and(app(DocumentExchangeRateRecorder::class))->toBeInstanceOf(MoneyDocumentExchangeRateRecorder::class) + ->and(app(EstimateEmailSender::class))->toBeInstanceOf(LaravelEstimateEmailSender::class) + ->and(app(InvoiceEmailSender::class))->toBeInstanceOf(LaravelInvoiceEmailSender::class) + ->and(Gate::getPolicyFor(Estimate::class))->toBeInstanceOf(EstimatePolicy::class) + ->and(Gate::getPolicyFor(Invoice::class))->toBeInstanceOf(InvoicePolicy::class) + ->and(Gate::getPolicyFor(RecurringInvoice::class))->toBeInstanceOf(RecurringInvoicePolicy::class) + ->and(Gate::has('send invoice'))->toBeTrue() + ->and(Gate::has('create credit note'))->toBeTrue() + ->and(Gate::has('send estimate'))->toBeTrue() + ->and(Gate::has('delete multiple invoices'))->toBeTrue() + ->and(Gate::has('delete multiple estimates'))->toBeTrue() + ->and(Gate::has('delete multiple recurring invoices'))->toBeTrue() + ->and(Artisan::all())->toHaveKeys(['check:estimates:status', 'check:invoices:status']); + + foreach ([ + 'App\\Services\\Document\\CreditNoteService', + 'App\\Services\\Document\\DocumentItemService', + 'App\\Services\\Document\\EstimateService', + 'App\\Services\\Document\\InvoiceBalanceService', + 'App\\Services\\Document\\InvoiceService', + 'App\\Services\\Document\\RecurringInvoiceService', + 'App\\Services\\Document\\SerialNumberService', + 'App\\Policies\\CreditNotePolicy', + 'App\\Policies\\EstimatePolicy', + 'App\\Policies\\InvoicePolicy', + 'App\\Policies\\RecurringInvoicePolicy', + 'App\\Jobs\\GenerateEstimatePdfJob', + 'App\\Jobs\\GenerateInvoicePdfJob', + 'App\\Mail\\EstimateViewedMail', + 'App\\Mail\\InvoiceViewedMail', + 'App\\Mail\\SendCreditNoteMail', + 'App\\Mail\\SendEstimateMail', + 'App\\Mail\\SendInvoiceMail', + 'App\\Http\\Controllers\\Company\\Estimate\\EstimatesController', + 'App\\Http\\Controllers\\Company\\Estimate\\EstimateTemplatesController', + 'App\\Http\\Controllers\\Company\\Invoice\\InvoicesController', + 'App\\Http\\Controllers\\Company\\Invoice\\InvoiceTemplatesController', + 'App\\Http\\Controllers\\Company\\RecurringInvoice\\RecurringInvoiceController', + 'App\\Http\\Controllers\\Company\\RecurringInvoice\\RecurringInvoiceFrequencyController', + 'App\\Http\\Controllers\\Company\\General\\SerialNumberController', + 'App\\Http\\Controllers\\CustomerPortal\\Estimate\\AcceptEstimateController', + 'App\\Http\\Controllers\\CustomerPortal\\Estimate\\EstimatesController', + 'App\\Http\\Controllers\\CustomerPortal\\Invoice\\InvoicesController', + 'App\\Http\\Controllers\\CustomerPortal\\EstimatePdfController', + 'App\\Http\\Controllers\\CustomerPortal\\InvoicePdfController', + 'App\\Http\\Controllers\\Pdf\\DocumentPdfController', + ] as $legacyClass) { + expect(class_exists($legacyClass))->toBeFalse(); + } +}); + +test('the sales domain preserves company document routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/v1/(?:invoices|estimates|recurring-invoices|recurring-invoice-frequency|next-number|number-placeholders)(?:$|/)#', + $route->uri(), + ) === 1); + + expect($routes)->toHaveCount(34); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Sales\\Http\\Controllers\\Company\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); + +test('the sales domain preserves customer and pdf routes', function () { + $customerRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/{company}/customer/invoices') + || str_starts_with($route->uri(), 'api/v1/{company}/customer/estimates') + || $route->uri() === 'api/v1/{company}/customer/estimate/{estimate}/status'); + + expect($customerRoutes)->toHaveCount(5); + + foreach ($customerRoutes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Sales\\Http\\Controllers\\CustomerPortal\\') + ->and($route->gatherMiddleware())->toContain('auth:customer', 'customer-portal'); + } + + $pdfRoutes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => in_array($route->uri(), [ + 'invoices/pdf/{invoice}', + 'estimates/pdf/{estimate}', + 'customer/invoices/{email_log}', + 'customer/invoices/view/{email_log}', + 'customer/estimates/{email_log}', + 'customer/estimates/view/{email_log}', + ], true)); + + expect($pdfRoutes)->toHaveCount(6); + + foreach ($pdfRoutes as $route) { + expect($route->getActionName())->toStartWith('App\\Domains\\Sales\\Http\\Controllers\\'); + } +}); diff --git a/tests/Feature/Architecture/StoragePlatformBoundaryTest.php b/tests/Feature/Architecture/StoragePlatformBoundaryTest.php new file mode 100644 index 00000000..870c8756 --- /dev/null +++ b/tests/Feature/Architecture/StoragePlatformBoundaryTest.php @@ -0,0 +1,60 @@ +getProviders(StorageServiceProvider::class))->toHaveCount(1) + ->and(app(StorageConfigurator::class))->toBeInstanceOf(FileDiskService::class) + ->and(Gate::has('manage backups'))->toBeTrue() + ->and(Gate::has('manage file disk'))->toBeTrue() + ->and(Artisan::all())->toHaveKey('media:secure'); + + expect(class_exists('App\\Providers\\DropboxServiceProvider'))->toBeFalse() + ->and(class_exists('App\\Services\\Storage\\FileDiskService'))->toBeFalse() + ->and(class_exists('App\\Jobs\\CreateBackupJob'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\BackupsController'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Admin\\Settings\\DiskController'))->toBeFalse(); +}); + +test('the storage platform preserves its public routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => preg_match( + '#^api/v1/(backups(?:/|$)|disks(?:/|$)|download-backup$|disk/(?:drivers|purposes)$)#', + $route->uri(), + ) === 1) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/backups/{backup}', + 'DELETE api/v1/disks/{disk}', + 'GET|HEAD api/v1/backups', + 'GET|HEAD api/v1/backups/{backup}', + 'GET|HEAD api/v1/disk/drivers', + 'GET|HEAD api/v1/disk/purposes', + 'GET|HEAD api/v1/disks', + 'GET|HEAD api/v1/disks/{disk}', + 'GET|HEAD api/v1/download-backup', + 'POST api/v1/backups', + 'POST api/v1/disks', + 'PUT api/v1/disk/purposes', + 'PUT|PATCH api/v1/backups/{backup}', + 'PUT|PATCH api/v1/disks/{disk}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName())->toStartWith('App\\Platform\\Storage\\Http\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } + + expect($routes->get('GET|HEAD api/v1/backups')->getActionName()) + ->toBe(BackupsController::class.'@index') + ->and($routes->get('GET|HEAD api/v1/disks')->getActionName()) + ->toBe(DiskController::class.'@index'); +}); diff --git a/tests/Feature/Architecture/TaxationDomainBoundaryTest.php b/tests/Feature/Architecture/TaxationDomainBoundaryTest.php new file mode 100644 index 00000000..1dd2a29b --- /dev/null +++ b/tests/Feature/Architecture/TaxationDomainBoundaryTest.php @@ -0,0 +1,40 @@ +getProviders(TaxationServiceProvider::class))->toHaveCount(1) + ->and(Gate::getPolicyFor(TaxType::class))->toBeInstanceOf(TaxTypePolicy::class); + + expect(class_exists('App\\Policies\\TaxTypePolicy'))->toBeFalse() + ->and(class_exists('App\\Http\\Controllers\\Company\\Settings\\TaxTypesController'))->toBeFalse() + ->and(class_exists('App\\Http\\Requests\\TaxTypeRequest'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\TaxTypeResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\TaxResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\TaxTypeResource'))->toBeFalse() + ->and(class_exists('App\\Http\\Resources\\Customer\\TaxResource'))->toBeFalse(); +}); + +test('the taxation domain preserves tax-type routes and middleware', function () { + $routes = collect(Route::getRoutes()->getRoutes()) + ->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/tax-types')) + ->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri()); + + expect($routes->keys()->sort()->values()->all())->toBe(collect([ + 'DELETE api/v1/tax-types/{tax_type}', + 'GET|HEAD api/v1/tax-types', + 'GET|HEAD api/v1/tax-types/{tax_type}', + 'POST api/v1/tax-types', + 'PUT|PATCH api/v1/tax-types/{tax_type}', + ])->sort()->values()->all()); + + foreach ($routes as $route) { + expect($route->getActionName()) + ->toStartWith('App\\Domains\\Taxation\\Http\\Controllers\\') + ->and($route->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer'); + } +}); diff --git a/tests/Feature/Company/BootstrapSecurityTest.php b/tests/Feature/Company/BootstrapSecurityTest.php index 9ed247a2..56aab0e6 100644 --- a/tests/Feature/Company/BootstrapSecurityTest.php +++ b/tests/Feature/Company/BootstrapSecurityTest.php @@ -1,7 +1,7 @@ 'runtime@example.com', ], $this->companyId); - CompanyMailConfigService::apply($this->companyId); + app(MailConfigurationService::class)->applyCompanyConfig($this->companyId); expect(config('mail.default'))->toBe('postmark'); expect(config('services.postmark.token'))->toBe('runtime-postmark-token'); diff --git a/tests/Feature/Company/ExchangeRate/BulkExchangeRateTest.php b/tests/Feature/Company/ExchangeRate/BulkExchangeRateTest.php new file mode 100644 index 00000000..df4c67ff --- /dev/null +++ b/tests/Feature/Company/ExchangeRate/BulkExchangeRateTest.php @@ -0,0 +1,53 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $this->user = User::findOrFail(1); + $this->companyId = $this->user->companies()->firstOrFail()->id; + $this->withHeaders(['company' => $this->companyId]); + Sanctum::actingAs($this->user, ['*']); +}); + +test('bulk exchange-rate setup still updates legacy documents through the domain contract', function () { + CompanySetting::setSettings(['bulk_exchange_rate_configured' => 'NO'], $this->companyId); + + $currency = Currency::findOrFail(1); + $invoice = Invoice::factory()->create([ + 'company_id' => $this->companyId, + 'currency_id' => $currency->id, + 'sub_total' => 100, + 'total' => 140, + 'tax' => 20, + 'due_amount' => 80, + 'exchange_rate' => null, + ]); + + postJson('/api/v1/currencies/bulk-update-exchange-rate', [ + 'currencies' => [[ + 'id' => $currency->id, + 'exchange_rate' => 2, + ]], + ])->assertOk()->assertJson(['success' => true]); + + $invoice->refresh(); + + expect($invoice->exchange_rate)->toBe(2.0) + ->and($invoice->base_discount_val)->toBe(200) + ->and($invoice->base_sub_total)->toBe(200) + ->and($invoice->base_total)->toBe(280) + ->and($invoice->base_tax)->toBe(40) + ->and($invoice->base_due_amount)->toBe(160) + ->and(CompanySetting::getSetting('bulk_exchange_rate_configured', $this->companyId)) + ->toBe('YES'); +}); diff --git a/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php b/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php index 332dbc0a..ab3d3981 100644 --- a/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php +++ b/tests/Feature/Company/ExchangeRate/ExchangeRateDriverListTest.php @@ -1,6 +1,6 @@ company->slug}/customer/me")->assertOk(); }); + +test('updating one address does not replace the other address', function () { + $customer = Auth::guard('customer')->user(); + $billing = Address::factory()->create([ + 'customer_id' => $customer->id, + 'type' => Address::BILLING_TYPE, + 'name' => 'Old Billing', + ]); + $shipping = Address::factory()->create([ + 'customer_id' => $customer->id, + 'type' => Address::SHIPPING_TYPE, + 'name' => 'Existing Shipping', + ]); + + postJson("api/v1/{$customer->company->slug}/customer/profile", [ + 'billing' => [ + 'name' => 'New Billing', + 'address_street_1' => 'Billing Street', + ], + ])->assertOk(); + + expect($customer->fresh()->billingAddress->name)->toBe('New Billing') + ->and($customer->fresh()->shippingAddress->is($shipping))->toBeTrue(); + + $this->assertDatabaseMissing('addresses', ['id' => $billing->id]); +}); diff --git a/tests/Feature/CustomerStatementTest.php b/tests/Feature/CustomerStatementTest.php index df67fc35..ad693782 100644 --- a/tests/Feature/CustomerStatementTest.php +++ b/tests/Feature/CustomerStatementTest.php @@ -1,13 +1,13 @@ build(); expect(EmailLog::query() - ->where('mailable_type', Customer::class) + ->where('mailable_type', $customer->getMorphClass()) ->where('mailable_id', $customer->id) ->where('from', 'configured@example.test') ->exists())->toBeTrue(); diff --git a/tests/Feature/Marketplace/CanonicalJsonTest.php b/tests/Feature/Marketplace/CanonicalJsonTest.php index ea829b5d..7ce5d354 100644 --- a/tests/Feature/Marketplace/CanonicalJsonTest.php +++ b/tests/Feature/Marketplace/CanonicalJsonTest.php @@ -1,6 +1,6 @@ stream() — already a Response + * payload, because GeneratesPdf wrapped $pdf->stream() — already a Response * — in another response()->make(). Readers scan for the header so nobody * noticed, but the bytes were malformed. The trait now passes ->output(), so the * position can be asserted, and a regression would be caught rather than diff --git a/tests/Feature/Pdf/PdfTemplateValidationTest.php b/tests/Feature/Pdf/PdfTemplateValidationTest.php index 95dcdd74..be650ae9 100644 --- a/tests/Feature/Pdf/PdfTemplateValidationTest.php +++ b/tests/Feature/Pdf/PdfTemplateValidationTest.php @@ -1,7 +1,7 @@ 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); +}); + +test('it stores replaces reads and clears expense receipts through the purchases contract', function () { + Storage::fake('local'); + + $expense = Expense::factory()->create(); + $manager = app(ExpenseReceiptManager::class); + + $uploadedReceipt = UploadedFile::fake()->create('first-receipt.pdf', 10, 'application/pdf'); + + $manager->attach($expense, new PendingExpenseReceipt( + $uploadedReceipt->getPathname(), + $uploadedReceipt->getClientOriginalName(), + )); + + $firstReceipt = $manager->first($expense); + + expect($firstReceipt)->not->toBeNull() + ->and($firstReceipt->fileName)->toBe('first-receipt.pdf') + ->and($expense->fresh()->getMedia('receipts'))->toHaveCount(1); + + $manager->attachBase64( + $expense, + 'data:image/png;base64,'.base64_encode('replacement receipt'), + 'replacement.png', + replaceExisting: true, + ); + + $replacement = $manager->first($expense); + + expect($replacement)->not->toBeNull() + ->and($replacement->fileName)->toBe('replacement.png') + ->and($expense->fresh()->getMedia('receipts'))->toHaveCount(1); + + $manager->clear($expense); + + expect($manager->first($expense))->toBeNull() + ->and($expense->fresh()->getMedia('receipts'))->toBeEmpty(); +}); diff --git a/tests/Feature/RealisticDemoSeederTest.php b/tests/Feature/RealisticDemoSeederTest.php index 102344e1..108bc20b 100644 --- a/tests/Feature/RealisticDemoSeederTest.php +++ b/tests/Feature/RealisticDemoSeederTest.php @@ -1,11 +1,11 @@ firstOrFail()->companies()->firstOrFail(); $from = Carbon::now()->startOfMonth(); $to = Carbon::now(); - $statementService = app(CustomerStatementService::class); + $statementQuery = app(CustomerStatementQuery::class); Customer::query() ->where('company_id', $company->id) - ->each(function (Customer $customer) use ($statementService, $from, $to): void { - $statement = $statementService->statement( + ->each(function (Customer $customer) use ($statementQuery, $from, $to): void { + $statement = $statementQuery->statement( $customer, - CustomerStatementService::TYPE_ACTIVITY, + CustomerStatementQuery::TYPE_ACTIVITY, $from, $to, ); diff --git a/tests/Feature/Receivables/PaymentEmailLogTest.php b/tests/Feature/Receivables/PaymentEmailLogTest.php new file mode 100644 index 00000000..264ddc59 --- /dev/null +++ b/tests/Feature/Receivables/PaymentEmailLogTest.php @@ -0,0 +1,39 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); +}); + +test('payment mail records a stable public link through the mail platform', function () { + $payment = Payment::factory()->create(); + $mail = new SendPaymentMail([ + 'from' => 'billing@example.com', + 'to' => 'customer@example.com', + 'subject' => 'Payment receipt', + 'body' => 'Thanks for your payment.', + 'payment' => $payment->toArray(), + 'attach' => ['data' => null], + ]); + + $mail->build(); + + $log = EmailLog::query()->sole(); + + expect($log->mailable_type)->toBe(ModelIdentityMap::aliasFor(Payment::class)) + ->and((int) $log->mailable_id)->toBe($payment->id) + ->and($log->token)->not->toBeEmpty() + ->and($mail->data['url'])->toBe(route('payment', ['email_log' => $log->token])); + + getJson('/customer/payments/'.$log->token) + ->assertOk() + ->assertJsonPath('data.id', $payment->id); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 2deca95e..bb47d2e7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -6,7 +6,17 @@ use Tests\TestCase; uses(TestCase::class, RefreshDatabase::class)->in('Feature'); uses(TestCase::class, RefreshDatabase::class)->in('Unit'); -// The module-system tests scaffold real modules on disk (Modules/ScaffoldProbe). -// Paratest isolates the database but not that shared filesystem path, so run this -// group serially after the parallel pass to avoid cross-worker collisions. -uses()->group('modules')->in('Feature/Company/Modules'); +// Module-system tests scaffold, install, and remove real directories under +// Modules. Paratest isolates the database but not that shared filesystem path, +// so every filesystem-mutating module suite runs serially after the parallel +// pass to avoid one worker scanning another worker's staging directory. +uses()->group('modules')->in( + 'Feature/Admin/Modules', + 'Feature/Company/Modules', + 'Feature/Marketplace', +); + +// Architecture assertions parse broad namespace graphs and retain that graph +// for the life of a worker. Run them in the serial phase so an ordinary feature +// test is not handed the parser's memory footprint in the same 128 MB process. +uses()->group('architecture')->in('Unit/Architecture', 'Feature/Architecture'); diff --git a/tests/Support/ScriptedAiDriver.php b/tests/Support/ScriptedAiDriver.php index cb862936..2d1ea15e 100644 --- a/tests/Support/ScriptedAiDriver.php +++ b/tests/Support/ScriptedAiDriver.php @@ -2,8 +2,8 @@ namespace Tests\Support; -use App\Support\Ai\AiChatResponse; -use App\Support\Ai\AiDriver; +use App\Platform\Ai\Contracts\AiDriver; +use App\Platform\Ai\Data\AiChatResponse; /** * Test double for AiDriver that returns pre-queued responses from an array, so diff --git a/tests/TestCase.php b/tests/TestCase.php index 0faafe0d..bc77f724 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,9 +2,7 @@ namespace Tests; -use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; -use Illuminate\Support\Str; use JMac\Testing\Traits\AdditionalAssertions; abstract class TestCase extends BaseTestCase @@ -19,17 +17,5 @@ abstract class TestCase extends BaseTestCase // (resources/views/app.blade.php → @vite) would throw ViteManifestNotFoundException. // Stub Vite so those views render without a built manifest. $this->withoutVite(); - - Factory::guessFactoryNamesUsing(function (string $modelName) { - // We can also customise where our factories live too if we want: - $namespace = 'Database\\Factories\\'; - - // Here we are getting the model name from the class namespace - $modelName = Str::afterLast($modelName, '\\'); - - // Finally we'll build up the full class path where - // Laravel will find our model factory - return $namespace.$modelName.'Factory'; - }); } } diff --git a/tests/Unit/AddressCountryLocalizationTest.php b/tests/Unit/AddressCountryLocalizationTest.php index eebcdb05..e2b6fb69 100644 --- a/tests/Unit/AddressCountryLocalizationTest.php +++ b/tests/Unit/AddressCountryLocalizationTest.php @@ -1,7 +1,7 @@ + */ +function phpFilesUnder(string $directory): array +{ + if (! is_dir($directory)) { + return []; + } + + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)); + + foreach ($iterator as $file) { + if ($file->isFile() && $file->getExtension() === 'php') { + $files[] = $file->getPathname(); + } + } + + return $files; +} + +test('domain code does not depend on legacy service or controller layers', function () { + $files = phpFilesUnder(app_path('Domains')); + + expect($files)->toBeArray(); + + foreach ($files as $file) { + $source = file_get_contents($file); + + expect($source)->not->toContain('App\\Http\\Controllers') + ->not->toContain('App\\Services'); + } +}); + +test('platform code does not depend on legacy controllers', function () { + foreach (phpFilesUnder(app_path('Platform')) as $file) { + expect(file_get_contents($file))->not->toContain('App\\Http\\Controllers'); + } +}); + +test('legacy application layer directories contain no php classes', function () { + $legacyDirectories = [ + 'Console/Commands', + 'Http/Controllers', + 'Http/Requests', + 'Http/Resources', + 'Jobs', + 'Mail', + 'Models', + 'Policies', + 'Services', + 'Traits', + ]; + + foreach ($legacyDirectories as $directory) { + expect(phpFilesUnder(app_path($directory)))->toBe([]); + } +}); + +test('support remains independent of domains', function () { + foreach (phpFilesUnder(app_path('Support')) as $file) { + expect(file_get_contents($file))->not->toContain('App\\Domains'); + } +}); diff --git a/tests/Unit/Architecture/HashidIdentityTest.php b/tests/Unit/Architecture/HashidIdentityTest.php new file mode 100644 index 00000000..93131ac3 --- /dev/null +++ b/tests/Unit/Architecture/HashidIdentityTest.php @@ -0,0 +1,23 @@ +value}"); + + expect($config)->toBeArray() + ->and($config['salt'])->toBe($legacyClass.config('app.key')); + + $legacy = new Hashids($legacyClass.config('app.key'), $config['length'], $config['alphabet']); + + expect(HashidsFacade::connection($connection->value)->encode(1))->toBe($legacy->encode(1)); +})->with([ + [HashidConnection::Invoice, 'App\\Models\\Invoice'], + [HashidConnection::Estimate, 'App\\Models\\Estimate'], + [HashidConnection::Payment, 'App\\Models\\Payment'], + [HashidConnection::Company, 'App\\Models\\Company'], + [HashidConnection::EmailLog, 'App\\Models\\EmailLog'], + [HashidConnection::Transaction, 'App\\Models\\Transaction'], +]); diff --git a/tests/Unit/Architecture/ModelIdentityMapTest.php b/tests/Unit/Architecture/ModelIdentityMapTest.php new file mode 100644 index 00000000..dede281a --- /dev/null +++ b/tests/Unit/Architecture/ModelIdentityMapTest.php @@ -0,0 +1,80 @@ +toHaveKey('company', Company::class) + ->toHaveKey('customer', Customer::class) + ->toHaveKey('invoice', Invoice::class) + ->toHaveKey('marketplace_credential', MarketplaceCredential::class) + ->toHaveKey('marketplace_operation', MarketplaceOperation::class) + ->toHaveKey('module', Module::class) + ->toHaveKey('user', User::class) + ->toHaveKey('bouncer_ability', Ability::class) + ->toHaveKey('bouncer_role', Role::class); + + expect((new Invoice)->getMorphClass())->toBe('invoice') + ->and((new User)->getMorphClass())->toBe('user') + ->and((new Role)->getMorphClass())->toBe('bouncer_role') + ->and(Relation::getMorphedModel('customer'))->toBe(Customer::class); +}); + +test('every first-party model has a stable identity', function () { + $models = collect([app_path('Models'), app_path('Domains'), app_path('Platform')]) + ->filter(fn (string $directory): bool => is_dir($directory)) + ->flatMap(function (string $directory): array { + $files = []; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)); + + foreach ($iterator as $file) { + $path = str_replace('\\', '/', $file->getPathname()); + + if ($file->isFile() && $file->getExtension() === 'php' && str_contains($path, '/Models/')) { + $relative = substr($path, strlen(str_replace('\\', '/', app_path())) + 1); + $files[] = 'App\\'.str_replace('/', '\\', substr($relative, 0, -4)); + } + } + + return $files; + }) + ->values(); + + $mappedModels = collect(ModelIdentityMap::aliases()) + ->values() + ->filter(fn (string $model): bool => str_starts_with($model, 'App\\')) + ->values(); + + expect($models->diff($mappedModels)->values()->all())->toBe([]) + ->and($mappedModels->diff($models)->values()->all())->toBe([]); +}); + +test('first-party models have canonical owners and explicit table contracts', function () { + expect(is_dir(app_path('Models')))->toBeFalse(); + + collect(ModelIdentityMap::aliases()) + ->values() + ->filter(fn (string $model): bool => str_starts_with($model, 'App\\')) + ->each(function (string $model): void { + $table = new ReflectionProperty($model, 'table'); + + expect($table->getDeclaringClass()->getName())->toBe($model) + ->and((new $model)->getTable())->not->toBeEmpty(); + }); +}); + +test('database aliases do not leak through the existing v1 discriminator', function () { + expect(ModelIdentityMap::publicType('invoice'))->toBe('App\\Models\\Invoice') + ->and(ModelIdentityMap::publicType('Modules\\Example\\Models\\Record')) + ->toBe('Modules\\Example\\Models\\Record'); +}); diff --git a/tests/Unit/CompanySettingTest.php b/tests/Unit/CompanySettingTest.php index 7a768b94..4b36b21f 100644 --- a/tests/Unit/CompanySettingTest.php +++ b/tests/Unit/CompanySettingTest.php @@ -1,7 +1,7 @@ assertTrue($fieldValue->customField()->exists()); }); + +test('custom field values are attached and updated through the metadata contract', function () { + $customField = CustomField::factory()->create([ + 'model_type' => 'Customer', + 'type' => 'Input', + ]); + $customer = Customer::factory()->create([ + 'company_id' => $customField->company_id, + ]); + $writer = app(CustomFieldValueWriter::class); + + $writer->attach($customer, [[ + 'id' => $customField->id, + 'value' => 'First value', + ]]); + + expect($customer->fields()->sole()->string_answer)->toBe('First value'); + + $writer->update($customer, [[ + 'id' => $customField->id, + 'value' => 'Updated value', + ]]); + + expect($customer->fields()->sole()->string_answer)->toBe('Updated value'); +}); diff --git a/tests/Unit/CustomerTest.php b/tests/Unit/CustomerTest.php index 91402664..278e17b6 100644 --- a/tests/Unit/CustomerTest.php +++ b/tests/Unit/CustomerTest.php @@ -1,7 +1,7 @@ replace($estimate); - $response = app(EstimateService::class)->create($request); + $response = app(EstimateService::class)->create( + attributes: $request->getEstimatePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('estimate_items', [ 'estimate_id' => $response->id, @@ -97,7 +101,12 @@ test('update estimate', function () { $number_attributes['estimate_number'] = $estimate_number[0].'-'.sprintf('%06d', intval($estimate_number[1])); - app(EstimateService::class)->update($estimate, $request); + app(EstimateService::class)->update( + estimate: $estimate, + attributes: $request->getEstimatePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('estimate_items', [ 'estimate_id' => $estimate->id, diff --git a/tests/Unit/ExchangeRateDriverFactoryTest.php b/tests/Unit/ExchangeRateDriverFactoryTest.php index 127481c0..49ca26c0 100644 --- a/tests/Unit/ExchangeRateDriverFactoryTest.php +++ b/tests/Unit/ExchangeRateDriverFactoryTest.php @@ -1,8 +1,8 @@ 'DatabaseSeeder', '--force' => true]); diff --git a/tests/Unit/ExpenseCategoryTest.php b/tests/Unit/ExpenseCategoryTest.php index 2232c36c..1ad47880 100644 --- a/tests/Unit/ExpenseCategoryTest.php +++ b/tests/Unit/ExpenseCategoryTest.php @@ -1,6 +1,6 @@ $taxType->id, ]); - app(CompanyService::class)->delete($company, $user); + app(CompanyService::class)->delete($company); $this->assertDatabaseMissing('taxes', ['id' => $tax->id]); }); @@ -116,7 +116,7 @@ test('company deletion removes payment allocations before bulk payment deletion' 'invoice_id' => $invoice->id, ]); - app(CompanyService::class)->delete($company, $user); + app(CompanyService::class)->delete($company); $this->assertDatabaseMissing('payment_allocations', ['id' => $allocation->id]); }); diff --git a/tests/Unit/FormatMoneyPdfTest.php b/tests/Unit/FormatMoneyPdfTest.php index 18ffae2f..4aa014ad 100644 --- a/tests/Unit/FormatMoneyPdfTest.php +++ b/tests/Unit/FormatMoneyPdfTest.php @@ -1,6 +1,6 @@ 'gotenberg', 'gotenberg_host' => $url, ])->rules(); diff --git a/tests/Unit/GotenbergPdfDriverTest.php b/tests/Unit/GotenbergPdfDriverTest.php index d9a95213..217c3dd9 100644 --- a/tests/Unit/GotenbergPdfDriverTest.php +++ b/tests/Unit/GotenbergPdfDriverTest.php @@ -1,6 +1,6 @@ create($request); + $response = app(InvoiceService::class)->create( + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('invoice_items', [ 'invoice_id' => $response->id, @@ -130,7 +134,12 @@ test('update invoice', function () { $number_attributes['invoice_number'] = $invoice_number[0].'-'.sprintf('%06d', intval($invoice_number[1])); - $response = app(InvoiceService::class)->update($invoice, $request); + $response = app(InvoiceService::class)->update( + invoice: $invoice, + attributes: $request->getInvoicePayload(), + items: $request->input('items'), + taxes: $request->input('taxes'), + ); $this->assertDatabaseHas('invoice_items', [ 'invoice_id' => $response->id, diff --git a/tests/Unit/ItemTest.php b/tests/Unit/ItemTest.php index 47dbaf6b..c7693514 100644 --- a/tests/Unit/ItemTest.php +++ b/tests/Unit/ItemTest.php @@ -1,10 +1,10 @@ Hi

"; @@ -36,7 +36,7 @@ it('normalizes legacy closing-br markup so lines are not collapsed in PDF output }); it('strips SSRF vectors injected via address-template placeholders', function () { - // Simulates the output of GeneratesPdfTrait::getFormattedString() after a + // Simulates the output of GeneratesPdf::getFormattedString() after a // malicious customer name like "Acme " has // been substituted into an address template via {BILLING_ADDRESS_NAME}. $html = "Acme
123 Main St
Springfield"; diff --git a/tests/Unit/PdfMetadataTest.php b/tests/Unit/PdfMetadataTest.php index 3c6f5f7c..cedc8e42 100644 --- a/tests/Unit/PdfMetadataTest.php +++ b/tests/Unit/PdfMetadataTest.php @@ -1,9 +1,9 @@