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
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
2 changes: 2 additions & 0 deletions Modules/Core/Support/PDF/Drivers/domPDF.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Modules\Core\Support\PDF\Drivers;

use Dompdf\Dompdf as PDF;
use Dompdf\Options;
use Modules\Core\Support\PDF\PDFAbstract;

class domPDF extends PDFAbstract
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 @@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Model;
use Modules\Core\Enums\Permission;
use Modules\Core\Filament\Company\Resources\BaseResource;
use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\EditInvoice;
use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices;
use Modules\Invoices\Filament\Company\Resources\Invoices\Schemas\InvoiceForm;
use Modules\Invoices\Filament\Company\Resources\Invoices\Tables\InvoicesTable;
Expand Down Expand Up @@ -61,6 +62,7 @@ public static function getPages(): array
{
return [
'index' => ListInvoices::route('/'),
'edit' => EditInvoice::route('/{record}/edit'),
];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,22 @@

namespace Modules\Invoices\Filament\Company\Resources\Invoices\Pages;

use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
use InvalidArgumentException;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource;
use Modules\Invoices\Services\InvoiceService;

class EditInvoice extends EditRecord
{
protected static string $resource = InvoiceResource::class;

public function mount($record): void
{
parent::mount($record);
}

public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void
{
$this->authorizeAccess();
Expand Down Expand Up @@ -49,7 +50,92 @@ protected function handleRecordUpdate(Model $record, array $data): Model
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
Action::make('preview')
->label(trans('ip.preview'))
->icon(Heroicon::OutlinedEye)
->modalHeading(fn () => trans('ip.invoice') . ' ' . ($this->getRecord()->invoice_number ?? trans('ip.draft')))
->modalContent(fn () => new HtmlString(
app(InvoiceService::class)->renderHtml($this->getRecord())
))
->modalSubmitAction(false)
->modalCancelActionLabel(trans('ip.close'))
->slideOver()
->modalWidth('3xl'),

Action::make('create_credit_note')
->label(trans('ip.create_credit_note'))
->icon(Heroicon::OutlinedDocumentMinus)
->visible(fn () => in_array($this->getRecord()->invoice_status, [
InvoiceStatus::SENT,
InvoiceStatus::PAID,
InvoiceStatus::PARTIALLY_PAID,
]))
->requiresConfirmation()
->action(function () {
try {
$creditNote = app(InvoiceService::class)->createCreditNote($this->getRecord());
} catch (InvalidArgumentException $e) {
Notification::make()
->title($e->getMessage())
->danger()
->send();

return;
}

Notification::make()
->title(trans('ip.credit_note_created'))
->success()
->send();

$this->redirect(InvoiceResource::getUrl('edit', ['record' => $creditNote]));
}),

Action::make('download_pdf')
->label(trans('ip.download_pdf'))
->icon(Heroicon::OutlinedArrowDownTray)
->action(fn () => app(InvoiceService::class)->generatePdf($this->getRecord())),

Action::make('send_email')
->label(trans('ip.send_email'))
->icon(Heroicon::OutlinedEnvelope)
->action(fn () => Notification::make()
->title(trans('ip.not_yet_implemented'))
->warning()
->send()),

Action::make('create_recurring')
->label(trans('ip.create_recurring'))
->icon(Heroicon::OutlinedArrowPath)
->action(fn () => Notification::make()
->title(trans('ip.not_yet_implemented'))
->warning()
->send()),

Action::make('copy_invoice')
->label(trans('ip.copy_invoice'))
->icon(Heroicon::OutlinedDocumentDuplicate)
->action(function (): void {
$original = $this->getRecord();
$copy = $original->replicate(['invoice_number', 'invoice_status']);
$copy->invoice_status = InvoiceStatus::DRAFT;
$copy->invoice_number = null;
$copy->save();

foreach ($original->invoiceItems as $item) {
$copy->invoiceItems()->create($item->only([
'product_id', 'item_name', 'description',
'quantity', 'price', 'discount',
'tax_rate_id', 'tax_rate_2_id',
]));
}

$this->redirect(InvoiceResource::getUrl('edit', ['record' => $copy]));
}),

DeleteAction::make()
->hidden(fn () => $this->getRecord()->invoice_status === InvoiceStatus::PAID
|| $this->getRecord()->is_read_only),
];
}
}
93 changes: 93 additions & 0 deletions Modules/Invoices/Services/InvoiceService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
use Illuminate\Support\Str;
use InvalidArgumentException;
use Modules\Core\Services\BaseService;
use Modules\Core\Support\PDF\PDFFactory;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;

class InvoiceService extends BaseService
Expand Down Expand Up @@ -197,6 +199,97 @@ public function deleteInvoice(Invoice $invoice): Invoice
return $invoice;
}

/**
* Render the invoice document markup used by both the PDF driver and
* the on-screen preview.
*/
public function renderHtml(Invoice $invoice): string
{
$invoice->loadMissing(['company', 'customer', 'invoiceItems']);

return view('invoices::pdf.invoice', ['invoice' => $invoice])->render();
}

/**
* Stream the invoice as a PDF download named after the invoice number.
*/
public function generatePdf(Invoice $invoice): StreamedResponse
{
$driver = PDFFactory::create();
$output = $driver->getOutput($this->renderHtml($invoice));
$filename = ($invoice->invoice_number ?: 'invoice-draft-' . $invoice->id) . '.pdf';

return response()->streamDownload(
function () use ($output): void {
echo $output;
},
$filename,
['Content-Type' => 'application/pdf'],
);
}

/**
* Issue a credit note for a sent/paid invoice: a mirrored draft invoice
* with negated amounts linked via creditinvoice_parent_id. The credit
* note may share the parent's number (the duplicate-number guard allows
* this), but starts unnumbered like every draft.
*/
public function createCreditNote(Invoice $invoice): Invoice
{
if ($invoice->creditinvoice_parent_id !== null) {
throw new InvalidArgumentException(trans('ip.cannot_credit_a_credit_note'));
}

return DB::transaction(function () use ($invoice) {
$creditNote = Invoice::query()->create([
'company_id' => $invoice->company_id,
'customer_id' => $invoice->customer_id,
'numbering_id' => $invoice->numbering_id,
'creditinvoice_parent_id' => $invoice->id,
'user_id' => auth()->id() ?? $invoice->user_id,
'invoice_number' => null,
'invoice_status' => InvoiceStatus::DRAFT->value,
'invoice_sign' => '-1',
'invoiced_at' => Carbon::today(),
'invoice_due_at' => Carbon::today()->addDays(30),
'invoice_discount_amount' => -1 * (float) $invoice->invoice_discount_amount,
'invoice_discount_percent' => $invoice->invoice_discount_percent,
'item_tax_total' => -1 * (float) $invoice->item_tax_total,
'invoice_item_subtotal' => -1 * (float) $invoice->invoice_item_subtotal,
'invoice_tax_total' => -1 * (float) $invoice->invoice_tax_total,
'invoice_total' => -1 * (float) $invoice->invoice_total,
'url_key' => Str::random(32),
'summary' => $invoice->summary,
'terms' => $invoice->terms,
'footer' => $invoice->footer,
]);

foreach ($invoice->invoiceItems as $item) {
$creditNote->invoiceItems()->create([
'company_id' => $item->company_id,
'product_id' => $item->product_id,
'product_unit_id' => $item->product_unit_id,
'task_id' => $item->task_id,
'added_at' => Carbon::today()->toDateString(),
'item_name' => $item->item_name,
'description' => $item->description,
'quantity' => $item->quantity,
'price' => -1 * (float) $item->price,
'discount' => $item->discount,
'subtotal' => -1 * (float) $item->subtotal,
'tax_1' => -1 * (float) $item->tax_1,
'tax_2' => -1 * (float) $item->tax_2,
'tax_total' => -1 * (float) $item->tax_total,
'total' => -1 * (float) $item->total,
'tax_rate_id' => $item->tax_rate_id,
'tax_rate_2_id' => $item->tax_rate_2_id,
]);
}

return $creditNote;
});
}

private function calculateItemTaxTotal(array $data): float
{
return collect($data['invoiceItems'] ?? [])->sum(fn ($item) => $item['tax'] ?? 0);
Expand Down
Loading
Loading