Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,8 @@ package-lock.json
*.sqlite
/failures.txt
/yarnpack.txt
/automation/.idea/
/automation/vendor/
/automation/test-honesty/vendor/
.claude/fable5/runtime/control.json
upd.sh
20 changes: 18 additions & 2 deletions Modules/Clients/Models/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Modules\Clients\Database\Factories\RelationFactory;
use Modules\Clients\Enums\CommunicationType;
use Modules\Clients\Enums\RelationStatus;
use Modules\Clients\Enums\RelationType;
use Modules\Core\Models\Company;
Expand Down Expand Up @@ -173,9 +174,24 @@ public function users(): HasMany
| Accessors
|--------------------------------------------------------------------------
*/
public function getCustomerEmailAttribute()
/**
* The relation's email address, resolved from the primary contact's
* email communications (there is no email column on relations itself).
* A primary email communication wins over any other email communication.
*/
public function getCustomerEmailAttribute(): ?string
{
return $this->email;
$contact = $this->primaryContact;

if ( ! $contact) {
return null;
}

$emails = $contact->communications
->where('communication_type', CommunicationType::EMAIL->value);

return $emails->firstWhere('is_primary', true)?->communication_value
?? $emails->first()?->communication_value;
}

/*public function getPrimaryContactAttribute(): string
Expand Down
1 change: 1 addition & 0 deletions Modules/Core/Database/Seeders/RolesSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function ($p) use ($customerResources) {
$isBasicAction = str_starts_with($p, 'view-')
|| str_starts_with($p, 'create-')
|| str_starts_with($p, 'edit-')
|| str_starts_with($p, 'delete-')
|| str_starts_with($p, 'export-')
|| str_starts_with($p, 'duplicate-');
$isCustomerResource = (bool) array_filter(
Expand Down
11 changes: 11 additions & 0 deletions Modules/Core/Tests/AbstractAdminPanelTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Carbon;
use Modules\Core\Database\Seeders\PermissionsSeeder;
use Modules\Core\Database\Seeders\RolesSeeder;
use Modules\Core\Enums\UserRole;
use Modules\Core\Models\Company;
use Modules\Core\Models\User;

Expand Down Expand Up @@ -34,6 +37,14 @@ protected function setUp(): void

session(['current_company_id' => $this->company->id]);

/*
* Admin resources gate every page on Spatie permissions (canViewAny
* etc.), so the test user needs the seeded super_admin permission set.
*/
(new PermissionsSeeder())->run();
(new RolesSeeder())->run();
$this->superAdmin->assignRole(UserRole::SUPER_ADMIN->value);

$this->withoutExceptionHandling();
}

Expand Down
11 changes: 11 additions & 0 deletions Modules/Core/Tests/AbstractCompanyPanelTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Carbon;
use Modules\Core\Database\Seeders\PermissionsSeeder;
use Modules\Core\Database\Seeders\RolesSeeder;
use Modules\Core\Enums\UserRole;
use Modules\Core\Models\Company;
use Modules\Core\Models\User;

Expand Down Expand Up @@ -44,6 +47,14 @@ protected function setUp(): void
$currentCompanyId = $this->user->getCurrentCompanyId();
session(['current_company_id' => $currentCompanyId]);

/*
* Resources gate every page on Spatie permissions (canViewAny etc.),
* so the test user needs the seeded client_admin permission set.
*/
(new PermissionsSeeder())->run();
(new RolesSeeder())->run();
$this->user->assignRole(UserRole::CUSTOMER_ADMIN->value);

$this->withoutExceptionHandling();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,25 @@
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use InvalidArgumentException;
use Modules\Core\Enums\NumberingType;
use Modules\Core\Enums\Permission;
use Modules\Core\Models\Numbering;
use Modules\Core\Support\DateHelpers;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;
use Modules\Invoices\Services\InvoiceCopyService;
use Modules\Invoices\Services\InvoiceService;
use Modules\Payments\Enums\PaymentMethod;
use Modules\Payments\Services\PaymentService;

class InvoicesTable
{
Expand Down Expand Up @@ -74,7 +85,15 @@ public static function configure(Table $table): Table
->sortable()
->toggleable(),
])
->filters([])
->filters([
SelectFilter::make('numbering_id')
->label(trans('ip.numbering'))
->options(fn (): array => Numbering::query()
->where('type', NumberingType::INVOICE->value)
->orderBy('name')
->pluck('name', 'id')
->toArray()),
])
->recordActions([
ActionGroup::make([
EditAction::make()
Expand Down Expand Up @@ -106,6 +125,67 @@ public static function configure(Table $table): Table
app(\Modules\Invoices\Services\InvoiceService::class)->updateInvoice($record, $data);
})
->modalWidth('full'),
Action::make('copy')
->visible(fn () => auth()->user()?->can(Permission::DUPLICATE_INVOICES->value))
->label(trans('ip.copy_invoice'))
->icon('heroicon-o-document-duplicate')
->requiresConfirmation()
->action(function (Invoice $record) {
app(InvoiceCopyService::class)->copy($record);
Notification::make()
->title(trans('ip.invoice_copied'))
->success()
->send();
}),
Action::make('enter_payment')
->label(trans('ip.enter_payment'))
->icon('heroicon-o-banknotes')
->visible(fn (Invoice $record) => auth()->user()?->can(Permission::CREATE_PAYMENTS->value)
&& in_array($record->invoice_status, [
InvoiceStatus::SENT,
InvoiceStatus::VIEWED,
InvoiceStatus::PARTIALLY_PAID,
InvoiceStatus::OVERDUE,
], true))
->schema([
Placeholder::make('invoice')
->label(trans('ip.invoice'))
->content(fn (Invoice $record) => mb_trim(
($record->invoice_number ?? '#' . $record->id)
. ' – ' . ($record->customer?->company_name ?? '')
)),
TextInput::make('payment_amount')
->label(trans('ip.payment_amount'))
->numeric()
->minValue(0.01)
->required(),
DatePicker::make('paid_at')
->label(trans('ip.paid_at'))
->required(),
Select::make('payment_method')
->label(trans('ip.payment_method'))
->options(
collect(PaymentMethod::cases())
->mapWithKeys(fn (PaymentMethod $method) => [
$method->value => $method->label(),
])
->toArray()
)
->native(false)
->required(),
])
->fillForm(fn (Invoice $record) => [
'payment_amount' => app(PaymentService::class)->amountOwed($record),
'paid_at' => now()->toDateString(),
])
->action(function (Invoice $record, array $data): void {
app(PaymentService::class)->enterInvoicePayment($record, $data);

Notification::make()
->title(trans('ip.payment_recorded'))
->success()
->send();
}),
Action::make('download pdf')
->visible(fn () => auth()->user()?->can(Permission::DOWNLOAD_INVOICES->value))
->label(trans('ip.download_pdf'))
Expand All @@ -117,6 +197,10 @@ public static function configure(Table $table): Table
Action::make('send email')
->visible(fn () => auth()->user()?->can(Permission::EMAIL_INVOICES->value))
->label(trans('ip.send_email'))
->disabled(fn (Invoice $record): bool => blank($record->customer?->customer_email))
->tooltip(fn (Invoice $record): ?string => blank($record->customer?->customer_email)
? trans('ip.customer_has_no_email')
: null)
->action(function (Invoice $record): void {
app(InvoiceService::class)->sendInvoiceEmail($record);
// Optionally, show a notification
Expand All @@ -127,7 +211,8 @@ public static function configure(Table $table): Table
->send();
}),
DeleteAction::make('delete')
->visible(fn () => auth()->user()?->can(Permission::DELETE_INVOICES->value))
->visible(fn (Invoice $record) => auth()->user()?->can(Permission::DELETE_INVOICES->value)
&& $record->invoice_status !== InvoiceStatus::PAID)
->action(function (Invoice $record, array $data) {
try {
app(InvoiceService::class)->deleteInvoice($record);
Expand Down
62 changes: 61 additions & 1 deletion Modules/Invoices/Services/InvoiceCopyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,64 @@

namespace Modules\Invoices\Services;

class InvoiceCopyService {}
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;

class InvoiceCopyService
{
/**
* Duplicate an invoice as a new Draft, without payments or transaction history.
* The copy gets a fresh url_key and a null invoice_number (assigned on edit).
*/
public function copy(Invoice $invoice): Invoice
{
return DB::transaction(function () use ($invoice) {
$copy = Invoice::query()->create([
'customer_id' => $invoice->customer_id,
'numbering_id' => $invoice->numbering_id,
'user_id' => auth()->id(),
'invoice_number' => null,
'invoice_status' => InvoiceStatus::DRAFT,
'invoice_sign' => $invoice->invoice_sign ?? '1',
'invoiced_at' => now(),
'invoice_due_at' => now()->addDays(30),
'invoice_discount_amount' => $invoice->invoice_discount_amount ?? 0,
'invoice_discount_percent' => $invoice->invoice_discount_percent ?? 0,
'item_tax_total' => $invoice->item_tax_total ?? 0,
'invoice_item_subtotal' => $invoice->invoice_item_subtotal ?? 0,
'invoice_tax_total' => $invoice->invoice_tax_total ?? 0,
'invoice_total' => $invoice->invoice_total ?? 0,
'url_key' => Str::random(32),
'is_read_only' => false,
'template' => $invoice->template,
'summary' => $invoice->summary,
'terms' => $invoice->terms,
'footer' => $invoice->footer,
]);

foreach ($invoice->invoiceItems as $item) {
$copy->invoiceItems()->create([
'product_id' => $item->product_id,
'product_unit_id' => $item->product_unit_id ?? null,
'item_name' => $item->item_name,
'quantity' => $item->quantity,
'price' => $item->price,
'discount' => $item->discount ?? 0,
'subtotal' => $item->subtotal,
'tax_1' => $item->tax_1 ?? 0,
'tax_2' => $item->tax_2 ?? 0,
'tax_total' => ($item->tax_1 ?? 0) + ($item->tax_2 ?? 0),
'total' => $item->total ?? 0,
'description' => $item->description,
'tax_rate_id' => $item->tax_rate_id,
'tax_rate_2_id' => $item->tax_rate_2_id,
'display_order' => $item->display_order,
]);
}

return $copy;
});
}
}
Loading
Loading