; do
- ahead=$(git rev-list origin/develop..origin/$branch --count)
- behind=$(git rev-list origin/$branch..origin/develop --count)
- echo "$branch → ahead=$ahead behind=$behind"
-done
-```
-
-Expected: all cleaned branches show `ahead=0 behind=0`.
-
----
-
-## Notes
-
-- Only force-push to branches that are NOT open PRs unless the PR is yours and you
- intend to update it.
-- GitHub Copilot branches (`copilot/*`) are AI-generated; resetting them is safe —
- Copilot will recreate them if needed.
-- The `--diff-filter=A` flag catches files the branch **adds** that develop lacks.
- Files the branch **modifies** relative to develop but which also exist in develop
- are not "unique" — develop's version is preferred.
-- Run `git fetch --prune` first so local remote-tracking refs are current.
diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md
deleted file mode 100644
index 5fd2f26cf..000000000
--- a/.claude/skills/tailwindcss-development/SKILL.md
+++ /dev/null
@@ -1,129 +0,0 @@
----
-name: tailwindcss-development
-description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes."
-license: MIT
-metadata:
- author: laravel
----
-
-# Tailwind CSS Development
-
-## When to Apply
-
-Activate this skill when:
-
-- Adding styles to components or pages
-- Working with responsive design
-- Implementing dark mode
-- Extracting repeated patterns into components
-- Debugging spacing or layout issues
-
-## Documentation
-
-Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
-
-## Basic Usage
-
-- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
-- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
-- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
-
-## Tailwind CSS v4 Specifics
-
-- Always use Tailwind CSS v4 and avoid deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-
-### CSS-First Configuration
-
-In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
-
-
-```css
-@theme {
- --color-brand: oklch(0.72 0.11 178);
-}
-```
-
-### Import Syntax
-
-In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
-
-
-```diff
-- @tailwind base;
-- @tailwind components;
-- @tailwind utilities;
-+ @import "tailwindcss";
-```
-
-### Replaced Utilities
-
-Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
-
-| Deprecated | Replacement |
-|------------|-------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
-
-## Spacing
-
-Use `gap` utilities instead of margins for spacing between siblings:
-
-
-```html
-
-```
-
-## Dark Mode
-
-If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
-
-
-```html
-
- Content adapts to color scheme
-
-```
-
-## Common Patterns
-
-### Flexbox Layout
-
-
-```html
-
-
Left content
-
Right content
-
-```
-
-### Grid Layout
-
-
-```html
-
-
Card 1
-
Card 2
-
Card 3
-
-```
-
-## Common Pitfalls
-
-- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
-- Using `@tailwind` directives instead of `@import "tailwindcss"`
-- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
-- Using margins for spacing between siblings instead of gap utilities
-- Forgetting to add dark mode variants when the project uses dark mode
diff --git a/.claude/skills/tenant-middleware/SKILL.md b/.claude/skills/tenant-middleware/SKILL.md
deleted file mode 100644
index cea871f52..000000000
--- a/.claude/skills/tenant-middleware/SKILL.md
+++ /dev/null
@@ -1,96 +0,0 @@
----
-name: tenant-middleware
-description: "Understands and modifies the tenant resolution middleware chain. Activates when debugging tenant switching, company access, session-based tenant resolution, URL-based tenant identification, or when the user mentions ConfigureTenant, EnsureUserCanAccessCompany, SetTenantFromQueryString, search_code, or company switching."
-license: MIT
-metadata:
- author: project
----
-
-# Tenant Middleware Chain
-
-Three middlewares run in order on every company panel request. They are registered
-as persistent tenant middleware in `CompanyPanelProvider`.
-
-## 1. SetTenantFromQueryString
-
-**Purpose:** Handle explicit `?tenant=` in the URL (used when switching company).
-
-- Reads the `tenant` query parameter (expects a `search_code` string)
-- Looks up the company by `search_code`
-- Checks the user has access (elevated role OR company membership)
-- Sets Filament tenant and writes `company_id` to session
-- Updates the `tenant` route parameter to the lowercase `search_code`
-
-## 2. ConfigureTenant
-
-**Purpose:** Resolve the active tenant from multiple sources and persist it.
-
-Resolution order:
-1. Route parameter (`{tenant}`)
-2. Query string `?tenant=`
-3. Session `current_company_id`
-4. User's first company (fallback)
-
-Writes resolved company to session and shares it with views.
-
-## 3. EnsureUserCanAccessCompany
-
-**Purpose:** Enforce that the resolved tenant is accessible to the authenticated user.
-
-- Elevated roles (`super_admin`, `admin`, `assist`) bypass — they can access all companies.
-- Regular users must have the company in their `companies()` pivot relationship.
-- Aborts 403 if the user has no access.
-
-## Company Identification
-
-Tenants are identified in URLs by `search_code` (a short alphanumeric string),
-not by numeric `id`. The session stores the numeric `id` (`current_company_id`).
-
-```php
-// URL: /company/invoices?tenant=ivplv2
-// Session: current_company_id = 22
-// Model: Company::where('search_code', 'ivplv2')->first() → id=22
-```
-
-## Switching Companies
-
-The "Switch Company" user menu action redirects with `?tenant=`:
-
-```php
-Action::make('switch-company')
- ->modalContent(fn () => view('filament.company.widgets.switch-company-table'))
-```
-
-The Livewire component inside that modal dispatches a redirect to the new tenant's URL.
-
-## Testing Tenant Switching
-
-```php
-Livewire::actingAs($this->user)
- ->test(SwitchCompanyComponent::class)
- ->callAction('switch', ['company_id' => $otherCompany->id])
- ->assertRedirect(route('filament.company.home', ['tenant' => $otherCompany->search_code]));
-```
-
-
-## Single Source of Truth
-
-Tenant resolution belongs exclusively in the tenant middleware chain.
-
-Controllers, Resources, Pages, Services, and Models must never independently
-resolve the active tenant from the request, session, or URL.
-
-They must rely on:
-
-- Filament::getTenant()
-- injected Company model
-- resolved route parameter
-
-Duplicating tenant resolution logic is an architectural defect.
-
-## Fix-One-Fix-All
-
-If one middleware requires modification due to a tenant resolution bug,
-review all three tenant middlewares for equivalent logic and consistency.
-
-Tenant resolution behavior must remain uniform across the entire middleware chain.
diff --git a/.claude/skills/test-honesty/SKILL.md b/.claude/skills/test-honesty/SKILL.md
deleted file mode 100644
index 954eb8d6a..000000000
--- a/.claude/skills/test-honesty/SKILL.md
+++ /dev/null
@@ -1,77 +0,0 @@
----
-name: test-honesty
-description: Ensures factory, seeder, and schema alignment with production database reality
----
-
-# Purpose
-
-Prevents schema drift between migrations, factories, and seeders.
-
----
-
-# 1. Schema Contract
-
-Every NOT NULL column defined in migrations must be supported by:
-
-- factory definition
-- or seeder definition (only for seed data)
-- or explicit DB default in migration
-
-This is a **schema-only rule**, not a validation rule.
-
----
-
-# 2. Factory Rule
-
-Factories MUST produce valid database rows for the schema.
-
-Factories are schema-aligned, not business-logic aware.
-
----
-
-# 3. Seeder Rule
-
-Seeders MUST only insert schema-valid data.
-
-No reliance on implicit database defaults.
-
----
-
-# 4. Database Parity Rule
-
-MySQL / MariaDB is the canonical database.
-
-SQLite differences are invalid for schema validation assumptions.
-
----
-
-# 5. Drift Triggers
-
-The following indicate schema drift:
-
-- migration changes
-- factory mismatch
-- seeder mismatch
-- SQLSTATE constraint violations
-- CI vs local DB mismatch
-
----
-
-# 6. Identity Rule
-
-Primary keys are non-deterministic.
-
-Tests MUST NOT rely on hardcoded IDs.
-
----
-
-# 7. Execution Rule (CI boundary)
-
-Schema validation requires:
-
-- migrate:fresh
-- seed
-
-before running test suites.
-
-This ensures schema correctness before test execution.
diff --git a/.claude/skills/user-auth-fields/SKILL.md b/.claude/skills/user-auth-fields/SKILL.md
deleted file mode 100644
index 365b68a84..000000000
--- a/.claude/skills/user-auth-fields/SKILL.md
+++ /dev/null
@@ -1,115 +0,0 @@
----
-name: user-auth-fields
-description: "Works with the User model's non-standard authentication fields. Activates when writing queries, factories, tests, or seeders that reference the user's email, name, or password; or when the user mentions user_email, user_name, user_password, authentication, login, or the User model."
-license: MIT
-metadata:
- author: project
----
-
-# User Authentication Fields
-
-This app's `users` table does NOT use Laravel's default `name`, `email`, and
-`password` column names. All three are prefixed with `user_`:
-
-| Laravel default | This app |
-|-----------------|----------|
-| `name` | `user_name` |
-| `email` | `user_email` |
-| `password` | `user_password` |
-
-## Model Overrides
-
-The `User` model overrides the auth contract methods:
-
-```php
-public function getAuthIdentifierName(): string
-{
- return 'user_name';
-}
-
-public function getAuthPassword(): string
-{
- return 'user_password';
-}
-```
-
-## Never Use the Default Column Names
-
-```php
-// ✗ WRONG — will cause "Column not found" on MySQL
-User::factory()->create(['name' => 'Test', 'email' => 'test@example.com']);
-
-// ✓ CORRECT
-User::factory()->create(['user_name' => 'Test', 'user_email' => 'test@example.com']);
-```
-
-This includes seeders, tests, and any `User::create()` call.
-
-## Factory Definition
-
-```php
-public function definition(): array
-{
- return [
- 'user_name' => fake()->name(),
- 'user_email' => fake()->unique()->safeEmail(),
- 'user_password' => Hash::make('password'),
- 'user_active' => fake()->boolean(90),
- 'user_all_clients' => fake()->boolean(90),
- 'user_date_created' => now(),
- 'user_date_modified' => now(),
- ];
-}
-```
-
-## Additional Non-Standard Fields
-
-| Standard concept | This app's column |
-|------------------|-------------------|
-| Timestamps | Manual: `user_date_created`, `user_date_modified` |
-| Active flag | `user_active` (boolean) |
-| `$timestamps` | `false` — managed manually |
-
-## Filament Name Display
-
-Filament uses `getFilamentName()` not `name`:
-
-```php
-public function getFilamentName(): string
-{
- return $this->user_name ?? $this->user_email ?? 'User';
-}
-```
-
----
-
-## Authentication Queries
-
-Never query using:
-
-email
-name
-password
-
-Always use:
-
-user_email
-user_name
-user_password
-
-including:
-
-- validation rules
-- login logic
-- factories
-- tests
-- seeders
-- authentication providers
-
----
-
-## Fix-One-Fix-All
-
-If one occurrence of `email`, `name`, or `password` is corrected to the
-application's custom fields, search for equivalent usages throughout the
-repository and update them consistently.
diff --git a/.github/IMPORTING.md b/.github/IMPORTING.md
index ca3009d31..2694d27d2 100644
--- a/.github/IMPORTING.md
+++ b/.github/IMPORTING.md
@@ -4,14 +4,14 @@ InvoicePlane supports importing data from external systems using CSV files. This
---
-## 📂 Accessing the Import Tool
+## Accessing the Import Tool
1. Navigate to **Settings**.
2. Click on **Import Data**.
---
-## 📄 Import Requirements
+## Import Requirements
To ensure a successful import:
@@ -26,64 +26,64 @@ To ensure a successful import:
---
-## 📁 Supported Files and Structures
+## Supported Files and Structures
### 1. `customers.csv`
-| Column Name | Description |
+| Column Name | Description |
|---------------------|-------------------------------------|
-| `client_name` | Customer's full name |
-| `client_address_1` | Primary address line |
-| `client_address_2` | Secondary address line |
-| `client_city` | City |
-| `client_state` | State or province |
-| `client_zip` | ZIP or postal code |
-| `client_country` | Country |
-| `client_phone` | Phone number |
-| `client_fax` | Fax number |
-| `client_mobile` | Mobile number |
-| `client_email` | Email address |
-| `client_web` | Website URL |
-| `client_vat_id` | VAT identification number |
-| `client_tax_code` | Tax code |
-| `client_active` | Status (`1` for active, `0` for inactive) |
+| `client_name` | Customer's full name |
+| `client_address_1` | Primary address line |
+| `client_address_2` | Secondary address line |
+| `client_city` | City |
+| `client_state` | State or province |
+| `client_zip` | ZIP or postal code |
+| `client_country` | Country |
+| `client_phone` | Phone number |
+| `client_fax` | Fax number |
+| `client_mobile` | Mobile number |
+| `client_email` | Email address |
+| `client_web` | Website URL |
+| `client_vat_id` | VAT identification number |
+| `client_tax_code` | Tax code |
+| `client_active` | Status (`1` for active, `0` for inactive) |
### 2. `invoices.csv`
-| Column Name | Description |
+| Column Name | Description |
|-------------------------|-------------------------------------------|
-| `user_email` | Email of the InvoicePlane user |
-| `client_name` | Name of the customer |
-| `invoice_date_created` | Creation date (`YYYY-MM-DD`) |
-| `invoice_date_due` | Due date (`YYYY-MM-DD`) |
-| `invoice_number` | Unique invoice number |
-| `invoice_terms` | Payment terms |
+| `user_email` | Email of the InvoicePlane user |
+| `client_name` | Name of the customer |
+| `invoice_date_created` | Creation date (`YYYY-MM-DD`) |
+| `invoice_date_due` | Due date (`YYYY-MM-DD`) |
+| `invoice_number` | Unique invoice number |
+| `invoice_terms` | Payment terms |
### 3. `invoice_items.csv`
-| Column Name | Description |
+| Column Name | Description |
|--------------------|-------------------------------------------|
-| `invoice_number` | Associated invoice number |
-| `item_tax_rate` | Tax rate (e.g., `7.8` for 7.8%) |
-| `item_date_added` | Date added (`YYYY-MM-DD`) |
-| `item_name` | Name of the item |
-| `item_description` | Description of the item |
-| `item_quantity` | Quantity of the item |
-| `item_price` | Price per item (numeric, no currency symbols) |
+| `invoice_number` | Associated invoice number |
+| `item_tax_rate` | Tax rate (e.g., `7.8` for 7.8%) |
+| `item_date_added` | Date added (`YYYY-MM-DD`) |
+| `item_name` | Name of the item |
+| `item_description` | Description of the item |
+| `item_quantity` | Quantity of the item |
+| `item_price` | Price per item (numeric, no currency symbols) |
### 4. `payments.csv`
-| Column Name | Description |
+| Column Name | Description |
|------------------|-------------------------------------------|
-| `invoice_number` | Associated invoice number |
-| `payment_method` | Method of payment (e.g., Cash, Credit) |
-| `payment_date` | Date of payment (`YYYY-MM-DD`) |
+| `invoice_number` | Associated invoice number |
+| `payment_method` | Method of payment (e.g., Cash, Credit) |
+| `payment_date` | Date of payment (`YYYY-MM-DD`) |
| `payment_amount` | Amount paid (numeric, no currency symbols)|
-| `payment_note` | Additional notes |
+| `payment_note` | Additional notes |
---
-## ⚠️ Important Notes
+## Important Notes
- **Custom Fields**: Importing custom fields is not supported in the current version.
- **Data Validation**: Ensure all data is accurate and conforms to the required formats to prevent import errors.
@@ -91,7 +91,7 @@ To ensure a successful import:
---
-## 🛠️ Troubleshooting
+## Troubleshooting
- **Import Errors**: If the import process fails, double-check file formats, headers, and data consistency.
- **Community Support**: For assistance, visit the [InvoicePlane Community Forums](https://community.invoiceplane.com/).
diff --git a/.gitignore b/.gitignore
index 525976bfe..b552606f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,53 +86,8 @@ package-lock.json
/audit-report.json
/failures.txt
/yarnpack.txt
-batch-implementation-status.md
-conflicts.md
-fetch-issues.sh
-fruiit.md
-gh-commands.sh
-gh-comments/
-issues-full.json
-link-dependencies.sh
-linked-issues-plan.md
-low-hanging-fruit.json
-plan-2026-07-17.md
-plan-prompt.md
-plan.md
-project-config.json
-refined-issues.json
-report-2026-07-17.md
-report.md
-scratch/
-.yarn/
-.yarnrc.yml
-agents/
-batch-order.json
-batch-summary.md
-excluded-issues.json
-merge-order.json
-state-map.json
-worktree-triage.md
-/automation/
-/.claude/fable5/
/automation/.idea/
/automation/vendor/
/automation/test-honesty/vendor/
.claude/fable5/runtime/control.json
upd.sh
-actual-real-resolved-issues.md
-current-issues.md
-merge-order.md
-"saving some issues.md"
-issues_full.json
-refine-issues.json
-/plans/
-feature-parity.md
-issues_index.json
-parity-results.md
-report-2026-07-18.md
-results-transcript.md
-summary-feature-parity.md
-plan-2026-07-19.md
-storage/dompdf_log
-untouched.json
diff --git a/Modules/Clients/Exports/ContactsExport.php b/Modules/Clients/Exports/ContactsExport.php
new file mode 100644
index 000000000..948254ffa
--- /dev/null
+++ b/Modules/Clients/Exports/ContactsExport.php
@@ -0,0 +1,47 @@
+contacts = $contacts;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->contacts;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.relation_id'),
+ trans('ip.type'),
+ trans('ip.contact_name'),
+ trans('ip.email'),
+ trans('ip.phone'),
+ trans('ip.gender'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->relation?->trading_name ?? $row->relation?->company_name ?? '',
+ $row->relation?->relation_type?->label() ?? '',
+ $row->full_name,
+ $row->email ?? null,
+ $row->phone ?? null,
+ $row->gender,
+ ];
+ }
+}
diff --git a/Modules/Clients/Exports/ContactsLegacyExport.php b/Modules/Clients/Exports/ContactsLegacyExport.php
new file mode 100644
index 000000000..91b9937eb
--- /dev/null
+++ b/Modules/Clients/Exports/ContactsLegacyExport.php
@@ -0,0 +1,47 @@
+contacts = $contacts;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->contacts;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.relation_id'),
+ trans('ip.type'),
+ trans('ip.contact_name'),
+ trans('ip.email'),
+ trans('ip.phone'),
+ trans('ip.gender'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->relation?->trading_name ?? $row->relation?->company_name ?? '',
+ $row->relation?->relation_type?->label() ?? '',
+ $row->full_name,
+ $row->email ?? null,
+ $row->phone ?? null,
+ $row->gender,
+ ];
+ }
+}
diff --git a/Modules/Clients/Exports/RelationsExport.php b/Modules/Clients/Exports/RelationsExport.php
new file mode 100644
index 000000000..d58c3f2e3
--- /dev/null
+++ b/Modules/Clients/Exports/RelationsExport.php
@@ -0,0 +1,57 @@
+relations = $relations;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->relations;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.primary_contact'),
+ trans('ip.relation_type'),
+ trans('ip.relation_status'),
+ trans('ip.relation_number'),
+ trans('ip.company_name'),
+ trans('ip.unique_name'),
+ trans('ip.coc_number'),
+ trans('ip.vat_number'),
+ trans('ip.language'),
+ trans('ip.email'),
+ trans('ip.phone'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->primary_contact,
+ $row->relation_type?->label() ?? '',
+ $row->relation_status?->label() ?? '',
+ $row->relation_number,
+ $row->company_name,
+ $row->unique_name,
+ $row->coc_number,
+ $row->vat_number,
+ $row->language,
+ $row->email ?? null,
+ $row->phone ?? null,
+ ];
+ }
+}
diff --git a/Modules/Clients/Exports/RelationsLegacyExport.php b/Modules/Clients/Exports/RelationsLegacyExport.php
new file mode 100644
index 000000000..0db944bb2
--- /dev/null
+++ b/Modules/Clients/Exports/RelationsLegacyExport.php
@@ -0,0 +1,43 @@
+relations = $relations;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->relations;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.relation_type'),
+ trans('ip.trading_name'), // or company_name if trading_name is not set
+ trans('ip.email'),
+ trans('ip.phone'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->relation_type?->label() ?? '',
+ $row->trading_name ?? $row->company_name,
+ $row->email,
+ $row->phone,
+ ];
+ }
+}
diff --git a/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php b/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php
index c6e016f33..d0f35412f 100644
--- a/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php
+++ b/Modules/Clients/Filament/Company/Resources/Contacts/Pages/ListContacts.php
@@ -2,9 +2,15 @@
namespace Modules\Clients\Filament\Company\Resources\Contacts\Pages;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Modules\Clients\Filament\Company\Resources\Contacts\ContactResource;
+use Modules\Clients\Filament\Exporters\ContactExporter;
+use Modules\Clients\Filament\Exporters\ContactLegacyExporter;
use Modules\Clients\Services\ContactService;
class ListContacts extends ListRecords
@@ -19,6 +25,31 @@ protected function getHeaderActions(): array
app(ContactService::class)->createContact($data);
})
->modalWidth('full'),
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ContactExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ContactLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(ContactExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(ContactLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php
index df4d6334e..056cda62b 100644
--- a/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php
+++ b/Modules/Clients/Filament/Company/Resources/Relations/Pages/ListRelations.php
@@ -2,9 +2,15 @@
namespace Modules\Clients\Filament\Company\Resources\Relations\Pages;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Modules\Clients\Filament\Company\Resources\Relations\RelationResource;
+use Modules\Clients\Filament\Exporters\RelationExporter;
+use Modules\Clients\Filament\Exporters\RelationLegacyExporter;
use Modules\Clients\Services\RelationService;
class ListRelations extends ListRecords
@@ -22,6 +28,32 @@ protected function getHeaderActions(): array
app(RelationService::class)->createRelation($data);
})
->modalWidth('full'),
+
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(RelationExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(RelationLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(RelationExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(RelationLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Clients/Filament/Exporters/ContactExporter.php b/Modules/Clients/Filament/Exporters/ContactExporter.php
new file mode 100644
index 000000000..176a70275
--- /dev/null
+++ b/Modules/Clients/Filament/Exporters/ContactExporter.php
@@ -0,0 +1,39 @@
+label(trans('ip.relation_id'))
+ ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''),
+ ExportColumn::make('type')
+ ->label(trans('ip.type'))
+ ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->relation_type?->label() ?? ''),
+ ExportColumn::make('full_name')
+ ->label(trans('ip.contact_name'))
+ ->formatStateUsing(fn ($state, Contact $record) => $record->full_name),
+ ExportColumn::make('email')
+ ->label(trans('ip.email')),
+ ExportColumn::make('phone')
+ ->label(trans('ip.phone')),
+ ExportColumn::make('gender')
+ ->label(trans('ip.gender'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.contact');
+ }
+}
diff --git a/Modules/Clients/Filament/Exporters/ContactLegacyExporter.php b/Modules/Clients/Filament/Exporters/ContactLegacyExporter.php
new file mode 100644
index 000000000..0b8de219d
--- /dev/null
+++ b/Modules/Clients/Filament/Exporters/ContactLegacyExporter.php
@@ -0,0 +1,39 @@
+label(trans('ip.relation_id'))
+ ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''),
+ ExportColumn::make('type')
+ ->label(trans('ip.type'))
+ ->formatStateUsing(fn ($state, Contact $record) => $record->relation?->relation_type?->label() ?? ''),
+ ExportColumn::make('full_name')
+ ->label(trans('ip.contact_name'))
+ ->formatStateUsing(fn ($state, Contact $record) => $record->full_name),
+ ExportColumn::make('email')
+ ->label(trans('ip.email')),
+ ExportColumn::make('phone')
+ ->label(trans('ip.phone')),
+ ExportColumn::make('gender')
+ ->label(trans('ip.gender'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.contact');
+ }
+}
diff --git a/Modules/Clients/Filament/Exporters/RelationExporter.php b/Modules/Clients/Filament/Exporters/RelationExporter.php
new file mode 100644
index 000000000..1e8221c00
--- /dev/null
+++ b/Modules/Clients/Filament/Exporters/RelationExporter.php
@@ -0,0 +1,47 @@
+label(trans('ip.primary_contact')),
+ ExportColumn::make('relation_type')
+ ->label(trans('ip.relation_type'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('relation_status')
+ ->label(trans('ip.relation_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('relation_number')
+ ->label(trans('ip.relation_number')),
+ ExportColumn::make('company_name')
+ ->label(trans('ip.company_name')),
+ ExportColumn::make('unique_name')
+ ->label(trans('ip.unique_name')),
+ ExportColumn::make('coc_number')
+ ->label(trans('ip.coc_number')),
+ ExportColumn::make('vat_number')
+ ->label(trans('ip.vat_number')),
+ ExportColumn::make('language')
+ ->label(trans('ip.language')),
+ ExportColumn::make('email')
+ ->label(trans('ip.email')),
+ ExportColumn::make('phone')
+ ->label(trans('ip.phone')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.relation');
+ }
+}
diff --git a/Modules/Clients/Filament/Exporters/RelationLegacyExporter.php b/Modules/Clients/Filament/Exporters/RelationLegacyExporter.php
new file mode 100644
index 000000000..e810680e3
--- /dev/null
+++ b/Modules/Clients/Filament/Exporters/RelationLegacyExporter.php
@@ -0,0 +1,33 @@
+label(trans('ip.relation_type'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('trading_name')
+ ->label(trans('ip.trading_name'))
+ ->formatStateUsing(fn ($state, Relation $record) => $record->trading_name ?? $record->company_name),
+ ExportColumn::make('email')
+ ->label(trans('ip.email')),
+ ExportColumn::make('phone')
+ ->label(trans('ip.phone')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.relation');
+ }
+}
diff --git a/Modules/Clients/Services/ContactExportService.php b/Modules/Clients/Services/ContactExportService.php
new file mode 100644
index 000000000..24bfcf824
--- /dev/null
+++ b/Modules/Clients/Services/ContactExportService.php
@@ -0,0 +1,34 @@
+where('company_id', $companyId)->get();
+ $fileName = 'contacts-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $version = config('ip.export_version', 2);
+ $exportClass = $version === 1 ? ContactsLegacyExport::class : ContactsExport::class;
+
+ return Excel::download(new $exportClass($contacts), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $contacts = Contact::query()->where('company_id', $companyId)->get();
+ $fileName = 'contacts-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? ContactsLegacyExport::class : ContactsExport::class;
+
+ return Excel::download(new $exportClass($contacts), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Clients/Services/RelationExportService.php b/Modules/Clients/Services/RelationExportService.php
new file mode 100644
index 000000000..812b4e11d
--- /dev/null
+++ b/Modules/Clients/Services/RelationExportService.php
@@ -0,0 +1,34 @@
+where('company_id', $companyId)->get();
+ $fileName = 'relations-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $version = config('ip.export_version', 2);
+ $exportClass = $version === 1 ? RelationsLegacyExport::class : RelationsExport::class;
+
+ return Excel::download(new $exportClass($relations), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $relations = Relation::query()->where('company_id', $companyId)->get();
+ $fileName = 'relations-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? RelationsLegacyExport::class : RelationsExport::class;
+
+ return Excel::download(new $exportClass($relations), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Clients/Tests/Feature/ClientsExportImportTest.php b/Modules/Clients/Tests/Feature/ClientsExportImportTest.php
new file mode 100644
index 000000000..502f41997
--- /dev/null
+++ b/Modules/Clients/Tests/Feature/ClientsExportImportTest.php
@@ -0,0 +1,153 @@
+for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relations = Relation::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_no_records(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ // No clients created
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_special_characters(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relation = Relation::factory()->for($this->company)->create([
+ 'company_name' => 'ÜClient, "Test"',
+ ]);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relations = Relation::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relations = Relation::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Company Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+}
diff --git a/Modules/Clients/Tests/Feature/RelationsExportImportTest.php b/Modules/Clients/Tests/Feature/RelationsExportImportTest.php
new file mode 100644
index 000000000..3b635e170
--- /dev/null
+++ b/Modules/Clients/Tests/Feature/RelationsExportImportTest.php
@@ -0,0 +1,153 @@
+for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Relation Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relations = Relation::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Relation Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_no_records(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ // No relations created
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Relation Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_special_characters(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relation = Relation::factory()->for($this->company)->create([
+ 'company_name' => 'ÜRelation, "Test"',
+ ]);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Relation Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relations = Relation::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Relation Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $relations = Relation::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListRelations::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'company_name' => ['isEnabled' => true, 'label' => 'Relation Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+}
diff --git a/Modules/Core/Commands/IMPORT_README.md b/Modules/Core/Commands/IMPORT_README.md
new file mode 100644
index 000000000..d3c10dabd
--- /dev/null
+++ b/Modules/Core/Commands/IMPORT_README.md
@@ -0,0 +1,233 @@
+# InvoicePlane v1 to v2 Database Import
+
+This document describes how to use the `import:db` command to migrate data from InvoicePlane v1 to InvoicePlane v2.
+
+## Overview
+
+The `import:db` command allows you to:
+- Import a complete InvoicePlane v1 database from a MySQL dump file
+- Map v1 data structures to v2 schema
+- Maintain all relationships between entities
+- Import into an existing company or create a new one
+
+## Requirements
+
+- InvoicePlane v1 MySQL database dump file
+- MySQL/MariaDB database server
+- PHP 8.2 or higher
+- Laravel 12+ with InvoicePlane v2 installed
+
+## Command Syntax
+
+```bash
+php artisan import:db [--company_id=]
+```
+
+### Arguments
+
+- `filename` (required): Filename of the SQL dump located in `storage/app/private/imports/`
+
+### Options
+
+- `--company_id` (optional): ID of an existing company to import data into. If not specified, a new company will be created.
+
+## Usage Examples
+
+### Import into a new company
+
+Place your dump file in `storage/app/private/imports/` and run:
+
+```bash
+php artisan import:db invoiceplane_v1_dump.sql
+```
+
+This will:
+1. Create a new company named "Imported from InvoicePlane v1"
+2. Import all data from the dump file into this company
+3. Display import statistics
+
+### Import into an existing company
+
+```bash
+php artisan import:db invoiceplane_v1_dump.sql --company_id=22
+```
+
+This will import all data into company with ID 22.
+
+## Data Import Order
+
+The import process follows dependency order to maintain referential integrity:
+
+1. **Tax Rates** - Import first as they're referenced by products and items
+2. **Product Categories** (v1: product_families) - Required for products
+3. **Product Units** - Required for products
+4. **Products** - Required for invoice/quote items
+5. **Clients** (v2: relations) - Required for invoices and quotes
+6. **Invoice Groups** (v2: numbering) - Used for invoice/quote numbering
+7. **Invoices** with Invoice Items - Main invoice data
+8. **Quotes** with Quote Items - Quote data
+9. **Payments** - Linked to invoices and customers
+
+## Data Mapping
+
+### Status Mappings
+
+#### Invoice Status (v1 → v2)
+- 1 → draft
+- 2 → sent
+- 3 → viewed
+- 4 → paid
+- 5 → overdue
+
+#### Quote Status (v1 → v2)
+- 1 → draft
+- 2 → sent
+- 3 → viewed
+- 4 → approved
+- 5 → rejected
+- 6 → canceled
+
+#### Payment Method (v1 → v2)
+- 1 → cash
+- 2 → bank_transfer
+- 3 → credit_card
+- 4 → paypal
+
+### Table Mappings
+
+| InvoicePlane v1 Table | InvoicePlane v2 Table | Notes |
+|-----------------------|-----------------------|-------|
+| `ip_families` | `product_categories` | Product families become categories |
+| `ip_units` | `product_units` | Direct mapping |
+| `ip_products` | `products` | With category and unit relationships |
+| `ip_clients` | `relations` | Clients become customer relations |
+| `ip_invoice_groups` | `numbering` | Invoice groups become numbering records |
+| `ip_invoices` | `invoices` | With customer relationship |
+| `ip_invoice_items` | `invoice_items` | With product and invoice relationships |
+| `ip_quotes` | `quotes` | With prospect relationship |
+| `ip_quote_items` | `quote_items` | With product and quote relationships |
+| `ip_payments` | `payments` | With invoice and customer relationships |
+| `ip_tax_rates` | `tax_rates` | Direct mapping |
+
+## Import Statistics
+
+After a successful import, the command displays statistics:
+
+```
+Import completed successfully!
++---------------------+-------+
+| Entity | Count |
++---------------------+-------+
+| Product Categories | 5 |
+| Product Units | 3 |
+| Products | 127 |
+| Clients | 42 |
+| Invoice Groups | 2 |
+| Invoices | 358 |
+| Invoice Items | 891 |
+| Quotes | 67 |
+| Quote Items | 134 |
+| Payments | 289 |
++---------------------+-------+
+```
+
+## Error Handling
+
+### Missing Tables
+The import service checks for table existence before importing. If a v1 table doesn't exist in the dump, it will be skipped without error.
+
+### Missing Dependencies
+- Invoices without clients will be skipped
+- Quotes without prospects will be skipped
+- Payments without invoices or customers will be skipped
+- Products without categories will be assigned to a default "Default" category
+
+### Database Errors
+If the dump restoration fails or database errors occur, the command will:
+1. Display the error message
+2. Show stack trace
+3. Return exit code 1
+4. Leave temporary database for debugging (can be manually dropped)
+
+## Technical Details
+
+### Temporary Database
+The import process:
+1. Creates a temporary database named `invoiceplane_v1_import`
+2. Restores the dump file to this database
+3. Reads data from temporary database
+4. Imports into v2 schema
+5. **Note:** Temporary database is kept for debugging purposes and should be manually dropped if needed
+
+### ID Mapping
+The service maintains internal ID mappings to preserve relationships:
+- Old v1 IDs are mapped to new v2 IDs
+- Relationships are updated to use new IDs
+- Foreign key constraints are respected
+
+### Default Values
+When v1 data is missing or incomplete:
+- Default user ID: Auto-assigned from existing users scoped to company
+- Default product type: "service"
+- Default payment status: "paid"
+- Default invoice/quote date: Current date
+
+## Troubleshooting
+
+### "Dump file not found" Error
+Ensure the file path is correct and the file exists:
+```bash
+ls -la /path/to/dump.sql
+```
+
+### "Failed to restore dump" Error
+Check:
+- MySQL credentials in `.env` file are correct
+- MySQL server is running
+- User has permission to create databases
+- Dump file is valid MySQL format
+
+### "Could not authenticate" Error
+Verify database credentials:
+```bash
+mysql -u username -p -e "SELECT 1"
+```
+
+### Memory Issues
+For large databases, you may need to increase PHP memory limit:
+```bash
+php -d memory_limit=512M artisan import:db dump.sql
+```
+
+## Testing
+
+The import functionality includes comprehensive PHPUnit tests:
+
+```bash
+# Run import tests only
+php artisan test --filter ImportInvoicePlaneV1CommandTest
+
+# Run with coverage
+php artisan test --filter ImportInvoicePlaneV1CommandTest --coverage
+```
+
+Test fixtures are located in: `Modules/Core/Tests/Fixtures/test_invoiceplane_v1_dump.sql`
+
+## Security Considerations
+
+- The command requires database credentials with CREATE DATABASE privilege
+- Temporary import database is kept after import for debugging and verification; drop it manually when no longer needed
+- SQL injection is prevented by using Laravel's query builder
+- File paths are validated before processing
+
+## Support
+
+For issues or questions:
+1. Check this README first
+2. Review error messages and stack traces
+3. Check database logs
+4. Open an issue on GitHub with:
+ - Error message
+ - InvoicePlane v1 version
+ - Database dump size/structure
+ - PHP and MySQL versions
diff --git a/Modules/Core/Commands/ImportInvoicePlaneV1Command.php b/Modules/Core/Commands/ImportInvoicePlaneV1Command.php
new file mode 100644
index 000000000..cc450f078
--- /dev/null
+++ b/Modules/Core/Commands/ImportInvoicePlaneV1Command.php
@@ -0,0 +1,66 @@
+argument('filename');
+ $companyId = $this->option('company_id');
+
+ $dumpPath = storage_path('app/private/imports/' . $filename);
+
+ if ( ! file_exists($dumpPath)) {
+ $this->error("Dump file not found: {$dumpPath}");
+ $this->info('Place your SQL dump file in: storage/app/private/imports/');
+
+ return self::FAILURE;
+ }
+
+ $this->info('Starting InvoicePlane v1 to v2 import...');
+ $this->info("Dump file: {$filename}");
+
+ if ($companyId) {
+ $this->info("Importing into existing company ID: {$companyId}");
+ } else {
+ $this->info('Creating new company for import...');
+ }
+
+ try {
+ $result = $importOrchestrator->import($filename, $companyId ? (int) $companyId : null);
+
+ $this->newLine();
+ $this->info('Import completed successfully!');
+
+ // Display statistics
+ $tableData = [];
+ foreach ($result as $entity => $count) {
+ $tableData[] = [ucwords(str_replace('_', ' ', $entity)), $count];
+ }
+
+ $this->table(['Entity', 'Count'], $tableData);
+
+ return self::SUCCESS;
+ } catch (Exception $e) {
+ $this->error('Import failed: ' . $e->getMessage());
+ if ($this->option('verbose')) {
+ $this->error('Stack trace: ' . $e->getTraceAsString());
+ }
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/Modules/Core/Filament/Exporters/BaseExporter.php b/Modules/Core/Filament/Exporters/BaseExporter.php
new file mode 100644
index 000000000..842e4475c
--- /dev/null
+++ b/Modules/Core/Filament/Exporters/BaseExporter.php
@@ -0,0 +1,31 @@
+ $entityName,
+ 'count' => number_format($export->successful_rows),
+ 'rows' => trans_choice('ip.row', $export->successful_rows),
+ ]);
+
+ if ($failedRowsCount = $export->getFailedRowsCount()) {
+ $body .= ' ' . trans('ip.export_failed_rows', [
+ 'count' => number_format($failedRowsCount),
+ 'rows' => trans_choice('ip.row', $failedRowsCount),
+ ]);
+ }
+
+ return $body;
+ }
+}
diff --git a/Modules/Core/Models/Import.php b/Modules/Core/Models/Import.php
new file mode 100644
index 000000000..85f9bd8d6
--- /dev/null
+++ b/Modules/Core/Models/Import.php
@@ -0,0 +1,23 @@
+commands([
- \Modules\Core\Commands\MigrateV1Command::class,
- \Modules\Core\Commands\MakeUserCommand::class,
- \Modules\Core\Commands\GenerateObservers::class,
+ \Modules\Core\Commands\ImportInvoicePlaneV1Command::class,
]);
}
diff --git a/Modules/Core/Services/Import/AbstractImportService.php b/Modules/Core/Services/Import/AbstractImportService.php
new file mode 100644
index 000000000..c83c17e1a
--- /dev/null
+++ b/Modules/Core/Services/Import/AbstractImportService.php
@@ -0,0 +1,76 @@
+tableExistsCache[$tableName])) {
+ return $this->tableExistsCache[$tableName];
+ }
+
+ try {
+ $tables = DB::connection(self::IMPORT_CONNECTION)
+ ->select('SHOW TABLES');
+
+ $tableKey = 'Tables_in_' . DB::connection(self::IMPORT_CONNECTION)->getDatabaseName();
+
+ foreach ($tables as $table) {
+ if (isset($table->{$tableKey}) && $table->{$tableKey} === $tableName) {
+ $this->tableExistsCache[$tableName] = true;
+
+ return true;
+ }
+ }
+
+ $this->tableExistsCache[$tableName] = false;
+
+ return false;
+ } catch (Exception $e) {
+ $this->tableExistsCache[$tableName] = false;
+
+ return false;
+ }
+ }
+
+ /**
+ * Get data from import database table.
+ */
+ protected function getImportData(string $tableName): \Illuminate\Support\Collection
+ {
+ if ( ! $this->tableExists($tableName)) {
+ return collect([]);
+ }
+
+ return DB::connection(self::IMPORT_CONNECTION)
+ ->table($tableName)
+ ->get();
+ }
+
+ /**
+ * Initialize statistics array.
+ */
+ protected function initStats(array $keys): void
+ {
+ foreach ($keys as $key) {
+ $this->stats[$key] = 0;
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/ClientsImportService.php b/Modules/Core/Services/Import/ClientsImportService.php
new file mode 100644
index 000000000..c104faa9e
--- /dev/null
+++ b/Modules/Core/Services/Import/ClientsImportService.php
@@ -0,0 +1,122 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['clients', 'contacts', 'addresses', 'communications']);
+
+ $this->importClients();
+ $this->importContacts();
+
+ return $this->stats;
+ }
+
+ private function importClients(): void
+ {
+ $clients = $this->getImportData('ip_clients');
+
+ foreach ($clients as $v1Client) {
+ $relation = Relation::create([
+ 'company_id' => $this->companyId,
+ 'relation_type' => 'customer',
+ 'relation_status' => ($v1Client->client_active ?? 1) == 1 ? 'active' : 'inactive',
+ 'relation_number' => $v1Client->client_name ?? 'CLIENT-' . $v1Client->client_id,
+ 'company_name' => $v1Client->client_name,
+ 'vat_number' => $v1Client->client_vat_id ?? null,
+ 'registered_at' => now(),
+ ]);
+
+ $this->idMappings['clients'][$v1Client->client_id] = $relation->id;
+ $this->stats['clients']++;
+
+ // Import address if available
+ if ( ! empty($v1Client->client_address_1) || ! empty($v1Client->client_city)) {
+ Address::create([
+ 'company_id' => $this->companyId,
+ 'address_type' => 'billing',
+ 'addressable_id' => $relation->id,
+ 'addressable_type' => Relation::class,
+ 'address_1' => $v1Client->client_address_1 ?? null,
+ 'address_2' => $v1Client->client_address_2 ?? null,
+ 'city' => $v1Client->client_city ?? null,
+ 'state_or_province' => $v1Client->client_state ?? null,
+ 'postal_code' => $v1Client->client_zip ?? null,
+ 'country' => $v1Client->client_country ?? null,
+ ]);
+
+ $this->stats['addresses']++;
+ }
+ }
+ }
+
+ private function importContacts(): void
+ {
+ $contacts = $this->getImportData('ip_contacts');
+
+ foreach ($contacts as $v1Contact) {
+ $relationId = $this->idMappings['clients'][$v1Contact->client_id] ?? null;
+
+ if ( ! $relationId) {
+ continue;
+ }
+
+ // Split contact name into first and last name
+ $contactName = $v1Contact->contact_name ?? 'Contact';
+ $nameParts = explode(' ', $contactName, 2);
+ $firstName = $nameParts[0];
+ $lastName = $nameParts[1] ?? '';
+
+ $contact = Contact::create([
+ 'company_id' => $this->companyId,
+ 'relation_id' => $relationId,
+ 'first_name' => $firstName,
+ 'last_name' => $lastName,
+ ]);
+
+ $this->stats['contacts']++;
+
+ // Import email as communication
+ if ( ! empty($v1Contact->contact_email)) {
+ Communication::create([
+ 'company_id' => $this->companyId,
+ 'communicationable_id' => $contact->id,
+ 'communicationable_type' => Contact::class,
+ 'is_primary' => true,
+ 'communication_type' => 'email',
+ 'communication_value' => $v1Contact->contact_email,
+ ]);
+
+ $this->stats['communications']++;
+ }
+
+ // Import phone as communication
+ if ( ! empty($v1Contact->contact_phone)) {
+ Communication::create([
+ 'company_id' => $this->companyId,
+ 'communicationable_id' => $contact->id,
+ 'communicationable_type' => Contact::class,
+ 'is_primary' => false,
+ 'communication_type' => 'phone',
+ 'communication_value' => $v1Contact->contact_phone,
+ ]);
+
+ $this->stats['communications']++;
+ }
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/CustomFieldsImportService.php b/Modules/Core/Services/Import/CustomFieldsImportService.php
new file mode 100644
index 000000000..0e5ec612a
--- /dev/null
+++ b/Modules/Core/Services/Import/CustomFieldsImportService.php
@@ -0,0 +1,92 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['custom_fields', 'custom_field_values']);
+
+ $this->importCustomFields();
+ $this->importCustomFieldValues();
+
+ return $this->stats;
+ }
+
+ private function importCustomFields(): void
+ {
+ $fields = $this->getImportData('ip_custom_fields');
+
+ foreach ($fields as $v1Field) {
+ $customField = CustomField::create([
+ 'company_id' => $this->companyId,
+ 'custom_field_table' => $v1Field->custom_field_table ?? 'invoices',
+ 'custom_field_label' => $v1Field->custom_field_label ?? 'Custom Field',
+ 'custom_field_column' => $v1Field->custom_field_column ?? null,
+ ]);
+
+ $this->idMappings['custom_fields'][$v1Field->custom_field_id] = $customField->id;
+ $this->stats['custom_fields']++;
+ }
+ }
+
+ private function importCustomFieldValues(): void
+ {
+ $values = $this->getImportData('ip_custom_values');
+
+ foreach ($values as $v1Value) {
+ $customFieldId = $this->idMappings['custom_fields'][$v1Value->custom_field_id] ?? null;
+
+ if ( ! $customFieldId) {
+ continue;
+ }
+
+ $entityType = $v1Value->entity_type ?? 'invoice';
+ $modelId = $this->resolveModelId($entityType, $v1Value->entity_id ?? null);
+
+ if ( ! $modelId) {
+ continue;
+ }
+
+ CustomFieldValue::create([
+ 'company_id' => $this->companyId,
+ 'custom_field_id' => $customFieldId,
+ 'model_id' => $modelId,
+ 'model_type' => ModelType::fromString($entityType)->value,
+ 'custom_field_value' => $v1Value->custom_field_value ?? '',
+ ]);
+
+ $this->stats['custom_field_values']++;
+ }
+ }
+
+ /**
+ * Resolve the model ID from entity type and legacy ID.
+ */
+ private function resolveModelId(string $entityType, ?int $legacyId): ?int
+ {
+ if ($legacyId === null) {
+ return null;
+ }
+
+ return match ($entityType) {
+ 'invoice' => $this->idMappings['invoices'][$legacyId] ?? null,
+ 'quote' => $this->idMappings['quotes'][$legacyId] ?? null,
+ 'client' => $this->idMappings['clients'][$legacyId] ?? null,
+ 'product' => $this->idMappings['products'][$legacyId] ?? null,
+ default => null,
+ };
+ }
+}
diff --git a/Modules/Core/Services/Import/EmailTemplatesImportService.php b/Modules/Core/Services/Import/EmailTemplatesImportService.php
new file mode 100644
index 000000000..a76bfe43e
--- /dev/null
+++ b/Modules/Core/Services/Import/EmailTemplatesImportService.php
@@ -0,0 +1,43 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['email_templates']);
+
+ $this->importEmailTemplates();
+
+ return $this->stats;
+ }
+
+ private function importEmailTemplates(): void
+ {
+ $templates = $this->getImportData('ip_email_templates');
+
+ foreach ($templates as $v1Template) {
+ EmailTemplate::create([
+ 'company_id' => $this->companyId,
+ 'title' => $v1Template->email_template_title ?? 'Template',
+ 'type' => $v1Template->email_template_type ?? 'default',
+ 'subject' => $v1Template->email_template_subject ?? '',
+ 'body' => $v1Template->email_template_body ?? '',
+ 'from_name' => $v1Template->email_template_from_name ?? null,
+ 'from_email' => $v1Template->email_template_from_email ?? null,
+ ]);
+
+ $this->stats['email_templates']++;
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/ImportOrchestrator.php b/Modules/Core/Services/Import/ImportOrchestrator.php
new file mode 100644
index 000000000..884a1f8a4
--- /dev/null
+++ b/Modules/Core/Services/Import/ImportOrchestrator.php
@@ -0,0 +1,230 @@
+ [],
+ 'clients' => [],
+ 'products' => [],
+ 'product_families' => [],
+ 'product_units' => [],
+ 'invoice_groups' => [],
+ 'invoices' => [],
+ 'quotes' => [],
+ 'tax_rates' => [],
+ 'projects' => [],
+ 'custom_fields' => [],
+ ];
+
+ private array $stats = [];
+
+ /**
+ * Import InvoicePlane v1 data from SQL dump file in storage.
+ *
+ * @param string $filename Filename in storage/app/private/imports
+ * @param int|null $companyId Company ID to import into (creates new if null)
+ *
+ * @return array Import statistics
+ */
+ public function import(string $filename, ?int $companyId = null): array
+ {
+ // Step 1: Setup company and user
+ $this->companyId = $companyId ?? $this->createCompany();
+ $this->userId = $this->getValidUserId();
+
+ // Step 2: Restore dump to import database
+ $this->restoreDump($filename);
+
+ try {
+ // Step 3: Import data using modular services
+ $this->runImportServices();
+
+ return $this->stats;
+ } finally {
+ // Step 4: Cleanup (optional - keep for debugging if needed)
+ // $this->cleanup();
+ }
+ }
+
+ /**
+ * Restore SQL dump to import database.
+ */
+ private function restoreDump(string $filename): void
+ {
+ $dumpPath = storage_path('app/private/imports/' . $filename);
+
+ if ( ! file_exists($dumpPath)) {
+ throw new RuntimeException("Dump file not found: {$dumpPath}");
+ }
+
+ try {
+ $config = config('database.connections.' . self::IMPORT_CONNECTION);
+
+ if ( ! is_array($config) || $config === []) {
+ throw new RuntimeException('Import database connection not configured');
+ }
+
+ $host = $config['host'] ?? throw new RuntimeException('Import database host not configured');
+ $port = $config['port'] ?? throw new RuntimeException('Import database port not configured');
+ $username = $config['username'] ?? throw new RuntimeException('Import database username not configured');
+ $password = $config['password'] ?? throw new RuntimeException('Import database password not configured');
+ $database = $config['database'] ?? throw new RuntimeException('Import database name not configured');
+
+ // Validate database name to prevent SQL injection
+ if ( ! preg_match('/^[A-Za-z0-9$_]+$/', $database)) {
+ throw new RuntimeException('Invalid database name: must contain only alphanumeric characters, dollar signs, and underscores');
+ }
+
+ // Create database if it doesn't exist on the same server as the import connection
+ $dsn = sprintf(
+ 'mysql:host=%s;port=%s;charset=%s',
+ $host,
+ $port,
+ $config['charset'] ?? 'utf8mb4'
+ );
+
+ $pdo = new PDO($dsn, $username, $password);
+ $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $pdo->exec("CREATE DATABASE IF NOT EXISTS `{$database}`");
+ unset($pdo);
+
+ // Use Laravel's DB to ensure connection works
+ DB::connection(self::IMPORT_CONNECTION)->getPdo();
+
+ // Use a temporary options file for credentials
+ $tmpFile = tempnam(sys_get_temp_dir(), 'mysql_import_');
+ file_put_contents($tmpFile, sprintf(
+ "[client]\nuser=%s\npassword=%s\nhost=%s\nport=%s\n",
+ $username,
+ $password,
+ $host,
+ $port
+ ));
+ chmod($tmpFile, 0600);
+
+ try {
+ $command = sprintf(
+ 'mysql --defaults-extra-file=%s %s < %s 2>&1',
+ escapeshellarg($tmpFile),
+ escapeshellarg($database),
+ escapeshellarg($dumpPath)
+ );
+
+ exec($command, $output, $returnCode);
+ } finally {
+ unlink($tmpFile);
+ }
+
+ if ($returnCode !== 0) {
+ throw new RuntimeException('Failed to restore dump: ' . implode("\n", $output));
+ }
+ } catch (Throwable $e) {
+ throw new RuntimeException('Database restoration failed: ' . $e->getMessage(), 0, $e);
+ }
+ }
+
+ /**
+ * Run all import services in correct order.
+ */
+ private function runImportServices(): void
+ {
+ $numberingService = new NumberingImportService();
+
+ $services = [
+ new UsersImportService(),
+ new TaxRatesImportService(),
+ new ProductsImportService(),
+ new ClientsImportService(),
+ $numberingService,
+ new InvoicesImportService($this->userId),
+ new QuotesImportService($this->userId),
+ new PaymentsImportService(),
+ new ProjectsImportService(),
+ new EmailTemplatesImportService(),
+ new CustomFieldsImportService(),
+ new SettingsImportService(),
+ new NotesImportService(),
+ ];
+
+ foreach ($services as $service) {
+ $serviceStats = $service->import($this->companyId, $this->idMappings);
+ $this->stats = array_merge($this->stats, $serviceStats);
+ }
+
+ // Apply proper numbering logic after all imports are complete
+ // This ensures numberings are correct and won't fail on next invoice/quote creation
+ $numberingService->applyNumberingLogic($this->companyId);
+ }
+
+ /**
+ * Create a new company for import.
+ */
+ private function createCompany(): int
+ {
+ $label = 'Imported from InvoicePlane v1';
+ $unique = Str::upper(Str::random(8));
+
+ $company = Company::create([
+ 'name' => $label,
+ 'slug' => 'imported-' . Str::lower($unique),
+ 'search_code' => $unique,
+ ]);
+
+ return $company->id;
+ }
+
+ /**
+ * Get or create a valid user ID scoped to the company.
+ */
+ private function getValidUserId(): int
+ {
+ // Find user belonging to the company
+ $user = User::whereHas('companies', fn ($q) => $q->where('companies.id', $this->companyId))->first();
+
+ if ($user) {
+ return $user->id;
+ }
+
+ // Create a new user and associate with company
+ $defaultUser = User::create([
+ 'name' => 'Import User',
+ 'email' => 'import-' . uniqid() . '@invoiceplane.local',
+ 'password' => bcrypt(str()->random(32)),
+ ]);
+
+ // Attach user to company
+ $defaultUser->companies()->attach($this->companyId);
+
+ return $defaultUser->id;
+ }
+
+ /**
+ * Optional cleanup of import database.
+ */
+ private function cleanup(): void
+ {
+ try {
+ $database = config('database.connections.' . self::IMPORT_CONNECTION . '.database');
+ DB::statement("DROP DATABASE IF EXISTS `{$database}`");
+ } catch (Exception $e) {
+ // Ignore cleanup errors
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/ImportServiceInterface.php b/Modules/Core/Services/Import/ImportServiceInterface.php
new file mode 100644
index 000000000..ed4c58531
--- /dev/null
+++ b/Modules/Core/Services/Import/ImportServiceInterface.php
@@ -0,0 +1,23 @@
+userId = $userId;
+ }
+
+ public function getTables(): array
+ {
+ return ['ip_invoices', 'ip_invoice_items'];
+ }
+
+ public function import(int $companyId, array &$idMappings): array
+ {
+ $this->companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['invoices', 'invoice_items']);
+
+ $this->importInvoices();
+
+ return $this->stats;
+ }
+
+ private function importInvoices(): void
+ {
+ $invoices = $this->getImportData('ip_invoices');
+ $allItems = collect($this->getImportData('ip_invoice_items'))->groupBy('invoice_id');
+
+ foreach ($invoices as $v1Invoice) {
+ $customerId = $this->idMappings['clients'][$v1Invoice->client_id] ?? null;
+ $numberingId = $this->idMappings['invoice_groups'][$v1Invoice->invoice_group_id] ?? null;
+
+ if ( ! $customerId) {
+ continue;
+ }
+
+ $invoice = Invoice::create([
+ 'company_id' => $this->companyId,
+ 'customer_id' => $customerId,
+ 'numbering_id' => $numberingId,
+ 'user_id' => $this->userId,
+ 'invoice_number' => $v1Invoice->invoice_number,
+ 'invoice_status' => $this->mapInvoiceStatus($v1Invoice->invoice_status_id ?? 1)->value,
+ 'invoiced_at' => $v1Invoice->invoice_date_created ?? now(),
+ 'invoice_due_at' => $v1Invoice->invoice_date_due ?? now()->addDays(30),
+ 'invoice_discount_percent' => $v1Invoice->invoice_discount_percent ?? 0,
+ 'invoice_discount_amount' => $v1Invoice->invoice_discount_amount ?? 0,
+ 'item_tax_total' => $v1Invoice->invoice_item_tax_total ?? 0,
+ 'invoice_item_subtotal' => $v1Invoice->invoice_item_subtotal ?? 0,
+ 'invoice_tax_total' => $v1Invoice->invoice_tax_total ?? 0,
+ 'invoice_total' => $v1Invoice->invoice_total ?? 0,
+ 'url_key' => $v1Invoice->invoice_url_key ?? null,
+ 'terms' => $v1Invoice->invoice_terms ?? null,
+ ]);
+
+ $this->idMappings['invoices'][$v1Invoice->invoice_id] = $invoice->id;
+ $this->stats['invoices']++;
+
+ $this->importInvoiceItems($allItems->get($v1Invoice->invoice_id, collect()), $invoice->id);
+ }
+ }
+
+ private function importInvoiceItems($v1Items, int $v2InvoiceId): void
+ {
+ foreach ($v1Items as $v1Item) {
+ $productId = $this->idMappings['products'][$v1Item->item_product_id] ?? null;
+ $taxRateId = $this->idMappings['tax_rates'][$v1Item->item_tax_rate_id] ?? null;
+
+ InvoiceItem::create([
+ 'company_id' => $this->companyId,
+ 'invoice_id' => $v2InvoiceId,
+ 'product_id' => $productId,
+ 'item_name' => $v1Item->item_name ?? 'Item',
+ 'quantity' => $v1Item->item_quantity ?? 1,
+ 'price' => $v1Item->item_price ?? 0,
+ 'discount' => $v1Item->item_discount_amount ?? 0,
+ 'tax_rate_id' => $taxRateId,
+ 'subtotal' => $v1Item->item_subtotal ?? 0,
+ 'tax_total' => $v1Item->item_tax_total ?? 0,
+ 'total' => $v1Item->item_total ?? 0,
+ 'description' => $v1Item->item_description ?? null,
+ 'display_order' => $v1Item->item_order ?? 0,
+ ]);
+
+ $this->stats['invoice_items']++;
+ }
+ }
+
+ private function mapInvoiceStatus(int $statusId): InvoiceStatus
+ {
+ return match ($statusId) {
+ 1 => InvoiceStatus::DRAFT,
+ 2 => InvoiceStatus::SENT,
+ 3 => InvoiceStatus::VIEWED,
+ 4 => InvoiceStatus::PAID,
+ 5 => InvoiceStatus::OVERDUE,
+ default => InvoiceStatus::DRAFT,
+ };
+ }
+}
diff --git a/Modules/Core/Services/Import/NotesImportService.php b/Modules/Core/Services/Import/NotesImportService.php
new file mode 100644
index 000000000..071fafd15
--- /dev/null
+++ b/Modules/Core/Services/Import/NotesImportService.php
@@ -0,0 +1,63 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['notes']);
+
+ $this->importNotes();
+
+ return $this->stats;
+ }
+
+ private function importNotes(): void
+ {
+ $notes = $this->getImportData('ip_notes');
+
+ foreach ($notes as $v1Note) {
+ $modelType = ModelType::fromString($v1Note->entity_type ?? 'invoice');
+ $modelId = $this->getModelId($modelType, $v1Note->entity_id ?? null);
+
+ if ( ! $modelId) {
+ continue;
+ }
+
+ Note::create([
+ 'company_id' => $this->companyId,
+ 'notable_id' => $modelId,
+ 'notable_type' => $modelType->value,
+ 'title' => $v1Note->note_title ?? 'Note',
+ 'content' => $v1Note->note ?? '',
+ ]);
+
+ $this->stats['notes']++;
+ }
+ }
+
+ private function getModelId(ModelType $modelType, ?int $entityId): ?int
+ {
+ if ( ! $entityId) {
+ return null;
+ }
+
+ return match ($modelType) {
+ ModelType::INVOICE => $this->idMappings['invoices'][$entityId] ?? null,
+ ModelType::QUOTE => $this->idMappings['quotes'][$entityId] ?? null,
+ ModelType::CLIENT => $this->idMappings['clients'][$entityId] ?? null,
+ default => null,
+ };
+ }
+}
diff --git a/Modules/Core/Services/Import/NumberingImportService.php b/Modules/Core/Services/Import/NumberingImportService.php
new file mode 100644
index 000000000..a3b851277
--- /dev/null
+++ b/Modules/Core/Services/Import/NumberingImportService.php
@@ -0,0 +1,105 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['invoice_groups']);
+
+ $this->importInvoiceGroups();
+
+ return $this->stats;
+ }
+
+ /**
+ * Apply proper numbering logic after invoices and quotes are imported
+ * This ensures numberings reflect the actual state and won't fail.
+ */
+ public function applyNumberingLogic(int $companyId): void
+ {
+ $numberings = Numbering::where('company_id', $companyId)
+ ->where('type', NumberingType::INVOICE->value)
+ ->get();
+
+ foreach ($numberings as $numbering) {
+ // Get all invoice numbers for this numbering to find highest numeric value
+ $invoiceNumbers = DB::table('invoices')
+ ->where('company_id', $companyId)
+ ->where('numbering_id', $numbering->id)
+ ->whereNotNull('invoice_number')
+ ->pluck('invoice_number');
+
+ if ($invoiceNumbers->isNotEmpty()) {
+ // Extract numeric parts from all invoice numbers and find max
+ $maxNumeric = $invoiceNumbers->map(function ($number) {
+ return (int) preg_replace('/[^0-9]/', '', $number);
+ })->max();
+
+ if ($maxNumeric) {
+ $numbering->update([
+ 'next_id' => $maxNumeric + 1,
+ ]);
+ }
+ }
+ }
+
+ // Apply similar logic for quote numberings
+ $quoteNumberings = Numbering::where('company_id', $companyId)
+ ->where('type', NumberingType::QUOTE->value)
+ ->get();
+
+ foreach ($quoteNumberings as $numbering) {
+ $quoteNumbers = DB::table('quotes')
+ ->where('company_id', $companyId)
+ ->where('numbering_id', $numbering->id)
+ ->whereNotNull('quote_number')
+ ->pluck('quote_number');
+
+ if ($quoteNumbers->isNotEmpty()) {
+ // Extract numeric parts from all quote numbers and find max
+ $maxNumeric = $quoteNumbers->map(function ($number) {
+ return (int) preg_replace('/[^0-9]/', '', $number);
+ })->max();
+
+ if ($maxNumeric) {
+ $numbering->update([
+ 'next_id' => $maxNumeric + 1,
+ ]);
+ }
+ }
+ }
+ }
+
+ private function importInvoiceGroups(): void
+ {
+ $groups = $this->getImportData('ip_invoice_groups');
+
+ foreach ($groups as $group) {
+ $numbering = Numbering::create([
+ 'company_id' => $this->companyId,
+ 'type' => NumberingType::INVOICE,
+ 'name' => $group->invoice_group_name,
+ 'next_id' => $group->invoice_group_next_id ?? 1,
+ 'left_pad' => 0,
+ 'format' => null,
+ 'prefix' => $group->invoice_group_prefix ?? 'INV',
+ ]);
+
+ $this->idMappings['invoice_groups'][$group->invoice_group_id] = $numbering->id;
+ $this->stats['invoice_groups']++;
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/PaymentsImportService.php b/Modules/Core/Services/Import/PaymentsImportService.php
new file mode 100644
index 000000000..592a48ffb
--- /dev/null
+++ b/Modules/Core/Services/Import/PaymentsImportService.php
@@ -0,0 +1,66 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['payments']);
+
+ $this->importPayments();
+
+ return $this->stats;
+ }
+
+ private function importPayments(): void
+ {
+ $payments = $this->getImportData('ip_payments');
+
+ foreach ($payments as $v1Payment) {
+ $invoiceId = $this->idMappings['invoices'][$v1Payment->invoice_id] ?? null;
+ $customerId = $this->idMappings['clients'][$v1Payment->client_id] ?? null;
+
+ if ( ! $invoiceId || ! $customerId) {
+ continue;
+ }
+
+ $payment = Payment::create([
+ 'company_id' => $this->companyId,
+ 'customer_id' => $customerId,
+ 'invoice_id' => $invoiceId,
+ 'payment_number' => null,
+ 'payment_method' => $this->mapPaymentMethod($v1Payment->payment_method_id ?? 1)->value,
+ 'payment_status' => PaymentStatus::COMPLETED->value,
+ 'paid_at' => $v1Payment->payment_date ?? now(),
+ 'payment_amount' => $v1Payment->payment_amount ?? 0,
+ 'notes' => $v1Payment->payment_note ?? null,
+ ]);
+
+ $this->idMappings['payments'][$v1Payment->id] = $payment->id;
+ $this->stats['payments']++;
+ }
+ }
+
+ private function mapPaymentMethod(int $methodId): PaymentMethod
+ {
+ return match ($methodId) {
+ 1 => PaymentMethod::CASH,
+ 2 => PaymentMethod::BANK_TRANSFER,
+ 3 => PaymentMethod::CREDIT_CARD,
+ 4 => PaymentMethod::PAYPAL,
+ default => PaymentMethod::BANK_TRANSFER,
+ };
+ }
+}
diff --git a/Modules/Core/Services/Import/ProductsImportService.php b/Modules/Core/Services/Import/ProductsImportService.php
new file mode 100644
index 000000000..f574c049b
--- /dev/null
+++ b/Modules/Core/Services/Import/ProductsImportService.php
@@ -0,0 +1,96 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['product_categories', 'product_units', 'products']);
+
+ $this->importProductCategories();
+ $this->importProductUnits();
+ $this->importProducts();
+
+ return $this->stats;
+ }
+
+ private function importProductCategories(): void
+ {
+ $families = $this->getImportData('ip_families');
+
+ foreach ($families as $family) {
+ $category = ProductCategory::create([
+ 'company_id' => $this->companyId,
+ 'category_name' => $family->family_name,
+ 'description' => null,
+ ]);
+
+ $this->idMappings['product_families'][$family->family_id] = $category->id;
+ $this->stats['product_categories']++;
+ }
+ }
+
+ private function importProductUnits(): void
+ {
+ $units = $this->getImportData('ip_units');
+
+ foreach ($units as $unit) {
+ $productUnit = ProductUnit::create([
+ 'company_id' => $this->companyId,
+ 'unit_name' => $unit->unit_name,
+ 'unit_name_plrl' => $unit->unit_name_plrl ?? $unit->unit_name,
+ ]);
+
+ $this->idMappings['product_units'][$unit->unit_id] = $productUnit->id;
+ $this->stats['product_units']++;
+ }
+ }
+
+ private function importProducts(): void
+ {
+ $products = $this->getImportData('ip_products');
+
+ foreach ($products as $v1Product) {
+ $categoryId = $this->idMappings['product_families'][$v1Product->family_id] ?? null;
+ $unitId = $this->idMappings['product_units'][$v1Product->unit_id] ?? null;
+ $taxRateId = $this->idMappings['tax_rates'][$v1Product->tax_rate_id] ?? null;
+
+ if ( ! $categoryId) {
+ $defaultCategory = ProductCategory::query()->firstOrCreate([
+ 'company_id' => $this->companyId,
+ 'category_name' => 'Default',
+ 'description' => 'Default category for imported products',
+ ]);
+ $categoryId = $defaultCategory->id;
+ }
+
+ $product = Product::create([
+ 'company_id' => $this->companyId,
+ 'category_id' => $categoryId,
+ 'unit_id' => $unitId,
+ 'type' => 'service',
+ 'code' => $v1Product->product_sku ?? null,
+ 'product_name' => $v1Product->product_name,
+ 'price' => $v1Product->product_price ?? 0,
+ 'tax_rate_id' => $taxRateId,
+ 'tax_rate_2_id' => null,
+ 'description' => $v1Product->product_description ?? null,
+ ]);
+
+ $this->idMappings['products'][$v1Product->product_id] = $product->id;
+ $this->stats['products']++;
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/ProjectsImportService.php b/Modules/Core/Services/Import/ProjectsImportService.php
new file mode 100644
index 000000000..2c65a06e0
--- /dev/null
+++ b/Modules/Core/Services/Import/ProjectsImportService.php
@@ -0,0 +1,76 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['projects', 'tasks']);
+
+ $this->importProjects();
+ $this->importTasks();
+
+ return $this->stats;
+ }
+
+ private function importProjects(): void
+ {
+ $projects = $this->getImportData('ip_projects');
+
+ foreach ($projects as $v1Project) {
+ $clientId = $this->idMappings['clients'][$v1Project->client_id] ?? null;
+
+ if ( ! $clientId) {
+ continue;
+ }
+
+ $project = Project::create([
+ 'company_id' => $this->companyId,
+ 'customer_id' => $clientId,
+ 'project_name' => $v1Project->project_name,
+ 'project_status' => $v1Project->project_status ?? 'active',
+ 'project_description' => $v1Project->project_description ?? null,
+ ]);
+
+ $this->idMappings['projects'][$v1Project->project_id] = $project->id;
+ $this->stats['projects']++;
+ }
+ }
+
+ private function importTasks(): void
+ {
+ $tasks = $this->getImportData('ip_tasks');
+
+ foreach ($tasks as $v1Task) {
+ $projectId = $this->idMappings['projects'][$v1Task->project_id] ?? null;
+ $customerId = $this->idMappings['clients'][$v1Task->customer_id] ?? null;
+
+ if ( ! $projectId || ! $customerId) {
+ continue;
+ }
+
+ Task::create([
+ 'company_id' => $this->companyId,
+ 'customer_id' => $customerId,
+ 'project_id' => $projectId,
+ 'task_name' => $v1Task->task_name,
+ 'task_description' => $v1Task->task_description ?? null,
+ 'task_status' => $v1Task->task_status ?? 'pending',
+ 'task_price' => $v1Task->task_price ?? 0,
+ ]);
+
+ $this->stats['tasks']++;
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/QuotesImportService.php b/Modules/Core/Services/Import/QuotesImportService.php
new file mode 100644
index 000000000..59575c143
--- /dev/null
+++ b/Modules/Core/Services/Import/QuotesImportService.php
@@ -0,0 +1,110 @@
+userId = $userId;
+ }
+
+ public function getTables(): array
+ {
+ return ['ip_quotes', 'ip_quote_items'];
+ }
+
+ public function import(int $companyId, array &$idMappings): array
+ {
+ $this->companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['quotes', 'quote_items']);
+
+ $this->importQuotes();
+
+ return $this->stats;
+ }
+
+ private function importQuotes(): void
+ {
+ $quotes = $this->getImportData('ip_quotes');
+ $allItems = collect($this->getImportData('ip_quote_items'))->groupBy('quote_id');
+
+ foreach ($quotes as $v1Quote) {
+ $prospectId = $this->idMappings['clients'][$v1Quote->client_id] ?? null;
+ $numberingId = $this->idMappings['invoice_groups'][$v1Quote->quote_group_id] ?? null;
+
+ if ( ! $prospectId) {
+ continue;
+ }
+
+ $quote = Quote::create([
+ 'company_id' => $this->companyId,
+ 'prospect_id' => $prospectId,
+ 'numbering_id' => $numberingId,
+ 'user_id' => $this->userId,
+ 'quote_number' => $v1Quote->quote_number,
+ 'quote_status' => $this->mapQuoteStatus($v1Quote->quote_status_id ?? 1)->value,
+ 'quoted_at' => $v1Quote->quote_date_created ?? now(),
+ 'quote_expires_at' => $v1Quote->quote_date_expires ?? now()->addDays(30),
+ 'quote_discount_percent' => $v1Quote->quote_discount_percent ?? 0,
+ 'quote_discount_amount' => $v1Quote->quote_discount_amount ?? 0,
+ 'item_tax_total' => $v1Quote->quote_item_tax_total ?? 0,
+ 'quote_item_subtotal' => $v1Quote->quote_item_subtotal ?? 0,
+ 'quote_tax_total' => $v1Quote->quote_tax_total ?? 0,
+ 'quote_total' => $v1Quote->quote_total ?? 0,
+ 'url_key' => $v1Quote->quote_url_key ?? null,
+ 'terms' => $v1Quote->quote_terms ?? null,
+ ]);
+
+ $this->idMappings['quotes'][$v1Quote->quote_id] = $quote->id;
+ $this->stats['quotes']++;
+
+ $this->importQuoteItems($allItems->get($v1Quote->quote_id, collect()), $quote->id);
+ }
+ }
+
+ private function importQuoteItems($v1Items, int $v2QuoteId): void
+ {
+ foreach ($v1Items as $v1Item) {
+ $productId = $this->idMappings['products'][$v1Item->item_product_id] ?? null;
+ $taxRateId = $this->idMappings['tax_rates'][$v1Item->item_tax_rate_id] ?? null;
+
+ QuoteItem::create([
+ 'company_id' => $this->companyId,
+ 'quote_id' => $v2QuoteId,
+ 'product_id' => $productId,
+ 'item_name' => $v1Item->item_name ?? 'Item',
+ 'quantity' => $v1Item->item_quantity ?? 1,
+ 'price' => $v1Item->item_price ?? 0,
+ 'discount' => $v1Item->item_discount_amount ?? 0,
+ 'tax_rate_id' => $taxRateId,
+ 'subtotal' => $v1Item->item_subtotal ?? 0,
+ 'tax_total' => $v1Item->item_tax_total ?? 0,
+ 'total' => $v1Item->item_total ?? 0,
+ 'description' => $v1Item->item_description ?? null,
+ 'display_order' => $v1Item->item_order ?? 0,
+ ]);
+
+ $this->stats['quote_items']++;
+ }
+ }
+
+ private function mapQuoteStatus(int $statusId): QuoteStatus
+ {
+ return match ($statusId) {
+ 1 => QuoteStatus::DRAFT,
+ 2 => QuoteStatus::SENT,
+ 3 => QuoteStatus::VIEWED,
+ 4 => QuoteStatus::APPROVED,
+ 5 => QuoteStatus::REJECTED,
+ default => QuoteStatus::DRAFT,
+ };
+ }
+}
diff --git a/Modules/Core/Services/Import/SettingsImportService.php b/Modules/Core/Services/Import/SettingsImportService.php
new file mode 100644
index 000000000..af23f5751
--- /dev/null
+++ b/Modules/Core/Services/Import/SettingsImportService.php
@@ -0,0 +1,44 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['settings']);
+
+ $this->importSettings();
+
+ return $this->stats;
+ }
+
+ private function importSettings(): void
+ {
+ $settings = $this->getImportData('ip_settings');
+
+ foreach ($settings as $v1Setting) {
+ // Note: Settings table doesn't have company_id in v2
+ // Settings are global across the system
+ Setting::updateOrCreate(
+ [
+ 'setting_key' => $v1Setting->setting_key,
+ ],
+ [
+ 'setting_value' => $v1Setting->setting_value ?? '',
+ ]
+ );
+
+ $this->stats['settings']++;
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/TaxRatesImportService.php b/Modules/Core/Services/Import/TaxRatesImportService.php
new file mode 100644
index 000000000..0df39c69c
--- /dev/null
+++ b/Modules/Core/Services/Import/TaxRatesImportService.php
@@ -0,0 +1,48 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['tax_rates']);
+
+ $this->importTaxRates();
+
+ return $this->stats;
+ }
+
+ private function importTaxRates(): void
+ {
+ $taxRates = $this->getImportData('ip_tax_rates');
+
+ foreach ($taxRates as $v1TaxRate) {
+ $v2TaxRate = TaxRate::query()->firstOrCreate(
+ [
+ 'company_id' => $this->companyId,
+ 'name' => $v1TaxRate->tax_rate_name ?? 'Tax',
+ 'rate' => $v1TaxRate->tax_rate_percent ?? 0,
+ ],
+ [
+ 'code' => mb_strtoupper(mb_substr($v1TaxRate->tax_rate_name ?? 'TAX', 0, 10)),
+ 'tax_rate_type' => TaxRateType::EXCLUSIVE->value,
+ 'is_active' => true,
+ ]
+ );
+
+ $this->idMappings['tax_rates'][$v1TaxRate->tax_rate_id] = $v2TaxRate->id;
+ $this->stats['tax_rates']++;
+ }
+ }
+}
diff --git a/Modules/Core/Services/Import/UsersImportService.php b/Modules/Core/Services/Import/UsersImportService.php
new file mode 100644
index 000000000..947f6b5f8
--- /dev/null
+++ b/Modules/Core/Services/Import/UsersImportService.php
@@ -0,0 +1,63 @@
+companyId = $companyId;
+ $this->idMappings = &$idMappings;
+ $this->initStats(['users']);
+
+ $this->importUsers();
+
+ return $this->stats;
+ }
+
+ private function importUsers(): void
+ {
+ $users = $this->getImportData('ip_users');
+
+ foreach ($users as $v1User) {
+ // Skip users without valid email
+ if (empty($v1User->user_email) || ! filter_var($v1User->user_email, FILTER_VALIDATE_EMAIL)) {
+ continue;
+ }
+
+ // Check if user already exists by email
+ $existingUser = User::where('email', $v1User->user_email)->first();
+
+ if ($existingUser) {
+ // Attach existing user to company if not already attached
+ if ( ! $existingUser->companies()->where('companies.id', $this->companyId)->exists()) {
+ $existingUser->companies()->attach($this->companyId);
+ }
+ $this->idMappings['users'][$v1User->user_id] = $existingUser->id;
+ continue;
+ }
+
+ $user = User::create([
+ 'name' => $v1User->user_name ?? 'Imported User',
+ 'email' => $v1User->user_email,
+ // For security, do not reuse legacy v1 password hashes.
+ // Always assign a new random password and require a password reset in v2.
+ 'password' => Hash::make(str()->random(32)),
+ ]);
+
+ // Attach new user to the target company
+ $user->companies()->attach($this->companyId);
+
+ $this->idMappings['users'][$v1User->user_id] = $user->id;
+ $this->stats['users']++;
+ }
+ }
+}
diff --git a/Modules/Core/Services/ImportInvoicePlaneV1Service.php b/Modules/Core/Services/ImportInvoicePlaneV1Service.php
new file mode 100644
index 000000000..24c4b7002
--- /dev/null
+++ b/Modules/Core/Services/ImportInvoicePlaneV1Service.php
@@ -0,0 +1,684 @@
+ [],
+ 'products' => [],
+ 'product_families' => [],
+ 'product_units' => [],
+ 'invoice_groups' => [],
+ 'quote_groups' => [],
+ 'invoices' => [],
+ 'quotes' => [],
+ 'tax_rates' => [],
+ ];
+
+ private array $stats = [
+ 'product_categories' => 0,
+ 'product_units' => 0,
+ 'products' => 0,
+ 'clients' => 0,
+ 'invoice_groups' => 0,
+ 'invoices' => 0,
+ 'invoice_items' => 0,
+ 'quotes' => 0,
+ 'quote_items' => 0,
+ 'payments' => 0,
+ ];
+
+ /**
+ * Import InvoicePlane v1 data from a mysqldump file.
+ */
+ public function import(string $dumpFile, ?int $companyId = null): array
+ {
+ // Step 1: Setup company
+ $this->companyId = $companyId ?? $this->createCompany();
+
+ // Step 2: Get or create a valid user
+ $this->userId = $this->getValidUserId();
+
+ try {
+ // Step 3: Create temporary database and restore dump
+ $this->createTemporaryDatabase();
+ $this->restoreDump($dumpFile);
+
+ // Step 4: Import data in dependency order
+ $this->importTaxRates();
+ $this->importProductFamilies();
+ $this->importProductUnits();
+ $this->importProducts();
+ $this->importClients();
+ $this->importInvoiceGroups();
+ $this->importQuoteGroups();
+ $this->importInvoices();
+ $this->importQuotes();
+ $this->importPayments();
+
+ return $this->stats;
+ } finally {
+ // Step 5: Cleanup temporary database
+ $this->dropTemporaryDatabase();
+ }
+ }
+
+ /**
+ * Create a new company for import.
+ */
+ private function createCompany(): int
+ {
+ $company = Company::create([
+ 'company_name' => 'Imported from InvoicePlane v1',
+ 'subdomain' => 'imported-' . uniqid(),
+ ]);
+
+ return $company->id;
+ }
+
+ /**
+ * Get or create a valid user ID.
+ */
+ private function getValidUserId(): int
+ {
+ // Try to find a user belonging to the company
+ $user = User::whereHas('companies', fn ($q) => $q->where('companies.id', $this->companyId))->first();
+
+ if ($user) {
+ return $user->id;
+ }
+
+ // Try to find any user and attach to company
+ $user = User::first();
+
+ if ($user) {
+ // Attach user to company if not already attached
+ if ( ! $user->companies()->where('companies.id', $this->companyId)->exists()) {
+ $user->companies()->attach($this->companyId);
+ }
+
+ return $user->id;
+ }
+
+ // If no users exist, create a default one
+ $defaultUser = User::create([
+ 'name' => 'Import User',
+ 'email' => 'import-' . uniqid() . '@invoiceplane.local',
+ 'password' => bcrypt(str()->random(32)),
+ ]);
+
+ // Attach to company
+ $defaultUser->companies()->attach($this->companyId);
+
+ return $defaultUser->id;
+ }
+
+ /**
+ * Create temporary database for import.
+ */
+ private function createTemporaryDatabase(): void
+ {
+ DB::statement('DROP DATABASE IF EXISTS ' . self::TEMP_DB_NAME);
+ DB::statement('CREATE DATABASE ' . self::TEMP_DB_NAME);
+ }
+
+ /**
+ * Restore mysqldump to temporary database.
+ */
+ private function restoreDump(string $dumpFile): void
+ {
+ $config = Config::get('database.connections.mysql');
+ $host = $config['host'];
+ $username = $config['username'];
+ $password = $config['password'];
+ $port = $config['port'] ?? 3306;
+
+ $passwordArg = $password ? '-p' . escapeshellarg($password) : '';
+ $command = sprintf(
+ 'mysql -h%s -P%s -u%s %s %s < %s 2>&1',
+ escapeshellarg($host),
+ escapeshellarg((string) $port),
+ escapeshellarg($username),
+ $passwordArg,
+ escapeshellarg(self::TEMP_DB_NAME),
+ escapeshellarg($dumpFile)
+ );
+
+ exec($command, $output, $returnCode);
+
+ if ($returnCode !== 0) {
+ throw new RuntimeException('Failed to restore dump: ' . implode("\n", $output));
+ }
+ }
+
+ /**
+ * Drop temporary database.
+ */
+ private function dropTemporaryDatabase(): void
+ {
+ DB::statement('DROP DATABASE IF EXISTS ' . self::TEMP_DB_NAME);
+ }
+
+ /**
+ * Check if a table exists in the temporary database.
+ */
+ private function tableExists(string $tableName): bool
+ {
+ try {
+ $result = DB::select(
+ 'SELECT COUNT(*) as count FROM information_schema.tables
+ WHERE table_schema = ? AND table_name = ?',
+ [self::TEMP_DB_NAME, $tableName]
+ );
+
+ return $result[0]->count > 0;
+ } catch (Throwable $e) {
+ // Check if it's just a "table not found" scenario vs a real error
+ $message = $e->getMessage();
+
+ // If the error is about the table not existing, return false
+ if (str_contains($message, "doesn't exist") || str_contains($message, 'Unknown table')) {
+ return false;
+ }
+
+ // For other errors (connection issues, permission errors, etc.), rethrow
+ throw new RuntimeException("Failed to check table existence for '{$tableName}': " . $message, 0, $e);
+ }
+ }
+
+ /**
+ * Import tax rates from v1.
+ */
+ private function importTaxRates(): void
+ {
+ if ( ! $this->tableExists('ip_tax_rates')) {
+ return;
+ }
+
+ $taxRates = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_tax_rates')
+ ->get();
+
+ foreach ($taxRates as $v1TaxRate) {
+ $v2TaxRate = TaxRate::create([
+ 'company_id' => $this->companyId,
+ 'name' => $v1TaxRate->tax_rate_name ?? 'Tax',
+ 'rate' => $v1TaxRate->tax_rate_percent ?? 0,
+ 'code' => mb_strtoupper(mb_substr($v1TaxRate->tax_rate_name ?? 'TAX', 0, 10)),
+ 'tax_rate_type' => 'sales',
+ 'is_active' => true,
+ ]);
+
+ $this->idMappings['tax_rates'][$v1TaxRate->tax_rate_id] = $v2TaxRate->id;
+ }
+ }
+
+ /**
+ * Import product families (categories) from v1.
+ */
+ private function importProductFamilies(): void
+ {
+ if ( ! $this->tableExists('ip_families')) {
+ return;
+ }
+
+ $families = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_families')
+ ->get();
+
+ foreach ($families as $family) {
+ $category = ProductCategory::create([
+ 'company_id' => $this->companyId,
+ 'category_name' => $family->family_name,
+ 'description' => null,
+ ]);
+
+ $this->idMappings['product_families'][$family->family_id] = $category->id;
+ $this->stats['product_categories']++;
+ }
+ }
+
+ /**
+ * Import product units from v1.
+ */
+ private function importProductUnits(): void
+ {
+ if ( ! $this->tableExists('ip_units')) {
+ return;
+ }
+
+ $units = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_units')
+ ->get();
+
+ foreach ($units as $unit) {
+ $productUnit = ProductUnit::create([
+ 'company_id' => $this->companyId,
+ 'unit_name' => $unit->unit_name,
+ 'unit_name_plrl' => $unit->unit_name_plrl ?? $unit->unit_name,
+ ]);
+
+ $this->idMappings['product_units'][$unit->unit_id] = $productUnit->id;
+ $this->stats['product_units']++;
+ }
+ }
+
+ /**
+ * Import products from v1.
+ */
+ private function importProducts(): void
+ {
+ if ( ! $this->tableExists('ip_products')) {
+ return;
+ }
+
+ $products = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_products')
+ ->get();
+
+ foreach ($products as $v1Product) {
+ $categoryId = $this->idMappings['product_families'][$v1Product->family_id] ?? null;
+ $unitId = $this->idMappings['product_units'][$v1Product->unit_id] ?? null;
+ $taxRateId = $this->idMappings['tax_rates'][$v1Product->tax_rate_id] ?? null;
+
+ if ( ! $categoryId) {
+ // Create default category if not found
+ $defaultCategory = ProductCategory::query()->firstOrCreate([
+ 'company_id' => $this->companyId,
+ 'category_name' => 'Default',
+ 'description' => 'Default category for imported products',
+ ]);
+ $categoryId = $defaultCategory->id;
+ }
+
+ $product = Product::create([
+ 'company_id' => $this->companyId,
+ 'category_id' => $categoryId,
+ 'unit_id' => $unitId,
+ 'type' => 'service', // Default to service
+ 'code' => $v1Product->product_sku ?? null,
+ 'product_name' => $v1Product->product_name,
+ 'price' => $v1Product->product_price ?? 0,
+ 'tax_rate_id' => $taxRateId,
+ 'description' => $v1Product->product_description ?? null,
+ ]);
+
+ $this->idMappings['products'][$v1Product->product_id] = $product->id;
+ $this->stats['products']++;
+ }
+ }
+
+ /**
+ * Import clients from v1.
+ */
+ private function importClients(): void
+ {
+ if ( ! $this->tableExists('ip_clients')) {
+ return;
+ }
+
+ $clients = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_clients')
+ ->get();
+
+ foreach ($clients as $v1Client) {
+ $relation = Relation::create([
+ 'company_id' => $this->companyId,
+ 'relation_type' => 'customer',
+ 'relation_status' => $v1Client->client_active == 1 ? 'active' : 'inactive',
+ 'relation_number' => $v1Client->client_name ?? 'CLIENT-' . $v1Client->client_id,
+ 'company_name' => $v1Client->client_name,
+ 'vat_number' => $v1Client->client_vat_id ?? null,
+ 'registered_at' => now(),
+ ]);
+
+ $this->idMappings['clients'][$v1Client->client_id] = $relation->id;
+ $this->stats['clients']++;
+ }
+ }
+
+ /**
+ * Import invoice groups (numbering) from v1.
+ */
+ private function importInvoiceGroups(): void
+ {
+ if ( ! $this->tableExists('ip_invoice_groups')) {
+ return;
+ }
+
+ $groups = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_invoice_groups')
+ ->get();
+
+ foreach ($groups as $group) {
+ $numbering = Numbering::create([
+ 'company_id' => $this->companyId,
+ 'type' => 'invoice',
+ 'name' => $group->invoice_group_name,
+ 'next_id' => $group->invoice_group_next_id ?? 1,
+ 'left_pad' => 0,
+ 'format' => $group->invoice_group_prefix ?? 'INV',
+ 'prefix' => $group->invoice_group_prefix ?? 'INV',
+ ]);
+
+ $this->idMappings['invoice_groups'][$group->invoice_group_id] = $numbering->id;
+ $this->stats['invoice_groups']++;
+ }
+ }
+
+ /**
+ * Import quote groups (numbering) from v1.
+ */
+ private function importQuoteGroups(): void
+ {
+ // Check if there's a separate ip_quote_groups table
+ if ($this->tableExists('ip_quote_groups')) {
+ $groups = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_quote_groups')
+ ->get();
+
+ foreach ($groups as $group) {
+ $numbering = Numbering::create([
+ 'company_id' => $this->companyId,
+ 'type' => 'quote',
+ 'name' => $group->quote_group_name ?? $group->invoice_group_name ?? 'Quote Group',
+ 'next_id' => $group->quote_group_next_id ?? $group->invoice_group_next_id ?? 1,
+ 'left_pad' => 0,
+ 'format' => $group->quote_group_prefix ?? $group->invoice_group_prefix ?? 'QTE',
+ 'prefix' => $group->quote_group_prefix ?? $group->invoice_group_prefix ?? 'QTE',
+ ]);
+
+ $this->idMappings['quote_groups'][$group->quote_group_id ?? $group->invoice_group_id] = $numbering->id;
+ }
+ }
+ }
+
+ /**
+ * Import invoices from v1.
+ */
+ private function importInvoices(): void
+ {
+ if ( ! $this->tableExists('ip_invoices')) {
+ return;
+ }
+
+ $invoices = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_invoices')
+ ->get();
+
+ // Preload all invoice items once to avoid per-invoice queries
+ $allInvoiceItems = [];
+ if ($this->tableExists('ip_invoice_items')) {
+ $items = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_invoice_items')
+ ->get();
+
+ foreach ($items as $item) {
+ $allInvoiceItems[$item->invoice_id][] = $item;
+ }
+ }
+
+ foreach ($invoices as $v1Invoice) {
+ $customerId = $this->idMappings['clients'][$v1Invoice->client_id] ?? null;
+ $numberingId = $this->idMappings['invoice_groups'][$v1Invoice->invoice_group_id] ?? null;
+
+ if ( ! $customerId) {
+ continue; // Skip invoices without clients
+ }
+
+ $invoice = Invoice::create([
+ 'company_id' => $this->companyId,
+ 'customer_id' => $customerId,
+ 'numbering_id' => $numberingId,
+ 'user_id' => $this->userId,
+ 'invoice_number' => $v1Invoice->invoice_number,
+ 'invoice_status' => $this->mapInvoiceStatus($v1Invoice->invoice_status_id ?? 1),
+ 'invoiced_at' => $v1Invoice->invoice_date_created ?? now(),
+ 'invoice_due_at' => $v1Invoice->invoice_date_due ?? now()->addDays(30),
+ 'invoice_discount_percent' => $v1Invoice->invoice_discount_percent ?? 0,
+ 'invoice_discount_amount' => $v1Invoice->invoice_discount_amount ?? 0,
+ 'item_tax_total' => $v1Invoice->invoice_item_tax_total ?? 0,
+ 'invoice_item_subtotal' => $v1Invoice->invoice_item_subtotal ?? 0,
+ 'invoice_tax_total' => $v1Invoice->invoice_tax_total ?? 0,
+ 'invoice_total' => $v1Invoice->invoice_total ?? 0,
+ 'url_key' => $v1Invoice->invoice_url_key ?? null,
+ 'terms' => $v1Invoice->invoice_terms ?? null,
+ ]);
+
+ $this->idMappings['invoices'][$v1Invoice->invoice_id] = $invoice->id;
+ $this->stats['invoices']++;
+
+ // Import invoice items from preloaded data
+ $this->importInvoiceItems($v1Invoice->invoice_id, $invoice->id, $allInvoiceItems);
+ }
+ }
+
+ /**
+ * Import invoice items for a specific invoice.
+ */
+ private function importInvoiceItems(int $v1InvoiceId, int $v2InvoiceId, array $allInvoiceItems): void
+ {
+ $items = $allInvoiceItems[$v1InvoiceId] ?? [];
+
+ foreach ($items as $v1Item) {
+ $productId = $this->idMappings['products'][$v1Item->item_product_id] ?? null;
+ $taxRateId = $this->idMappings['tax_rates'][$v1Item->item_tax_rate_id] ?? null;
+
+ InvoiceItem::create([
+ 'company_id' => $this->companyId,
+ 'invoice_id' => $v2InvoiceId,
+ 'product_id' => $productId,
+ 'item_name' => $v1Item->item_name ?? 'Item',
+ 'quantity' => $v1Item->item_quantity ?? 1,
+ 'price' => $v1Item->item_price ?? 0,
+ 'discount' => $v1Item->item_discount_amount ?? 0,
+ 'tax_rate_id' => $taxRateId,
+ 'subtotal' => $v1Item->item_subtotal ?? 0,
+ 'tax_total' => $v1Item->item_tax_total ?? 0,
+ 'total' => $v1Item->item_total ?? 0,
+ 'description' => $v1Item->item_description ?? null,
+ 'display_order' => $v1Item->item_order ?? 0,
+ ]);
+
+ $this->stats['invoice_items']++;
+ }
+ }
+
+ /**
+ * Import quotes from v1.
+ */
+ private function importQuotes(): void
+ {
+ if ( ! $this->tableExists('ip_quotes')) {
+ return;
+ }
+
+ $quotes = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_quotes')
+ ->get();
+
+ // Preload all quote items once to avoid per-quote queries
+ $allQuoteItems = [];
+ if ($this->tableExists('ip_quote_items')) {
+ $items = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_quote_items')
+ ->get();
+
+ foreach ($items as $item) {
+ $allQuoteItems[$item->quote_id][] = $item;
+ }
+ }
+
+ foreach ($quotes as $v1Quote) {
+ $prospectId = $this->idMappings['clients'][$v1Quote->client_id] ?? null;
+ $numberingId = $this->idMappings['quote_groups'][$v1Quote->quote_group_id] ?? null;
+
+ if ( ! $prospectId) {
+ continue; // Skip quotes without clients
+ }
+
+ $quote = Quote::create([
+ 'company_id' => $this->companyId,
+ 'prospect_id' => $prospectId,
+ 'numbering_id' => $numberingId,
+ 'user_id' => $this->userId,
+ 'quote_number' => $v1Quote->quote_number,
+ 'quote_status' => $this->mapQuoteStatus($v1Quote->quote_status_id ?? 1),
+ 'quoted_at' => $v1Quote->quote_date_created ?? now(),
+ 'quote_expires_at' => $v1Quote->quote_date_expires ?? now()->addDays(30),
+ 'quote_discount_percent' => $v1Quote->quote_discount_percent ?? 0,
+ 'quote_discount_amount' => $v1Quote->quote_discount_amount ?? 0,
+ 'item_tax_total' => $v1Quote->quote_item_tax_total ?? 0,
+ 'quote_item_subtotal' => $v1Quote->quote_item_subtotal ?? 0,
+ 'quote_tax_total' => $v1Quote->quote_tax_total ?? 0,
+ 'quote_total' => $v1Quote->quote_total ?? 0,
+ 'url_key' => $v1Quote->quote_url_key ?? null,
+ 'terms' => $v1Quote->quote_terms ?? null,
+ ]);
+
+ $this->idMappings['quotes'][$v1Quote->quote_id] = $quote->id;
+ $this->stats['quotes']++;
+
+ // Import quote items from preloaded data
+ $this->importQuoteItems($v1Quote->quote_id, $quote->id, $allQuoteItems);
+ }
+ }
+
+ /**
+ * Import quote items for a specific quote.
+ */
+ private function importQuoteItems(int $v1QuoteId, int $v2QuoteId, array $allQuoteItems): void
+ {
+ $items = $allQuoteItems[$v1QuoteId] ?? [];
+
+ foreach ($items as $v1Item) {
+ $productId = $this->idMappings['products'][$v1Item->item_product_id] ?? null;
+ $taxRateId = $this->idMappings['tax_rates'][$v1Item->item_tax_rate_id] ?? null;
+
+ QuoteItem::create([
+ 'company_id' => $this->companyId,
+ 'quote_id' => $v2QuoteId,
+ 'product_id' => $productId,
+ 'item_name' => $v1Item->item_name ?? 'Item',
+ 'quantity' => $v1Item->item_quantity ?? 1,
+ 'price' => $v1Item->item_price ?? 0,
+ 'discount' => $v1Item->item_discount_amount ?? 0,
+ 'tax_rate_id' => $taxRateId,
+ 'subtotal' => $v1Item->item_subtotal ?? 0,
+ 'tax_total' => $v1Item->item_tax_total ?? 0,
+ 'total' => $v1Item->item_total ?? 0,
+ 'description' => $v1Item->item_description ?? null,
+ 'display_order' => $v1Item->item_order ?? 0,
+ ]);
+
+ $this->stats['quote_items']++;
+ }
+ }
+
+ /**
+ * Import payments from v1.
+ */
+ private function importPayments(): void
+ {
+ if ( ! $this->tableExists('ip_payments')) {
+ return;
+ }
+
+ $payments = DB::connection('mysql')
+ ->table(self::TEMP_DB_NAME . '.ip_payments')
+ ->get();
+
+ foreach ($payments as $v1Payment) {
+ $invoiceId = $this->idMappings['invoices'][$v1Payment->invoice_id] ?? null;
+ $customerId = $this->idMappings['clients'][$v1Payment->client_id] ?? null;
+
+ if ( ! $invoiceId || ! $customerId) {
+ continue; // Skip payments without invoices or customers
+ }
+
+ Payment::create([
+ 'company_id' => $this->companyId,
+ 'customer_id' => $customerId,
+ 'invoice_id' => $invoiceId,
+ 'payment_number' => null,
+ 'payment_method' => $this->mapPaymentMethod($v1Payment->payment_method_id ?? 1),
+ 'payment_status' => 'paid',
+ 'paid_at' => $v1Payment->payment_date ?? now(),
+ 'payment_amount' => $v1Payment->payment_amount ?? 0,
+ 'notes' => $v1Payment->payment_note ?? null,
+ ]);
+
+ $this->stats['payments']++;
+ }
+ }
+
+ /**
+ * Map v1 invoice status to v2.
+ */
+ private function mapInvoiceStatus(int $statusId): string
+ {
+ return match ($statusId) {
+ 1 => 'draft',
+ 2 => 'sent',
+ 3 => 'viewed',
+ 4 => 'paid',
+ 5 => 'overdue',
+ default => 'draft',
+ };
+ }
+
+ /**
+ * Map v1 quote status to v2.
+ */
+ private function mapQuoteStatus(int $statusId): string
+ {
+ return match ($statusId) {
+ 1 => 'draft',
+ 2 => 'sent',
+ 3 => 'viewed',
+ 4 => 'approved',
+ 5 => 'rejected',
+ 6 => 'canceled',
+ default => 'draft',
+ };
+ }
+
+ /**
+ * Map v1 payment method to v2.
+ */
+ private function mapPaymentMethod(int $methodId): string
+ {
+ return match ($methodId) {
+ 1 => 'cash',
+ 2 => 'bank_transfer',
+ 3 => 'credit_card',
+ 4 => 'paypal',
+ default => 'other',
+ };
+ }
+}
diff --git a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php
index 2eeee2e36..30fa6823b 100644
--- a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php
+++ b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php
@@ -6,7 +6,6 @@
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Carbon;
-use Livewire\Livewire;
use Modules\Core\Database\Seeders\PermissionsSeeder;
use Modules\Core\Database\Seeders\RolesSeeder;
use Modules\Core\Enums\UserRole;
diff --git a/Modules/Core/Tests/Feature/ImportInvoicePlaneV1CommandTest.php b/Modules/Core/Tests/Feature/ImportInvoicePlaneV1CommandTest.php
new file mode 100644
index 000000000..ec52b258f
--- /dev/null
+++ b/Modules/Core/Tests/Feature/ImportInvoicePlaneV1CommandTest.php
@@ -0,0 +1,361 @@
+/dev/null')) === '') {
+ $this->markTestSkipped('mysql CLI binary not found; install mariadb-client to run import tests');
+ }
+
+ // The import:db command expects the dump file to live under
+ // storage/app/private/imports and receives only the basename.
+ $this->dumpFile = 'test_invoiceplane_v1_dump.sql';
+
+ $fixturePath = module_path('Core', 'Tests/Fixtures/' . $this->dumpFile);
+
+ // Ensure test dump file exists at the module fixture path
+ if ( ! file_exists($fixturePath)) {
+ $this->fail('Test dump file not found: ' . $fixturePath);
+ }
+
+ $importsPath = storage_path('app/private/imports');
+
+ if ( ! is_dir($importsPath) && ! mkdir($importsPath, 0777, true) && ! is_dir($importsPath)) {
+ $this->fail('Unable to create imports directory: ' . $importsPath);
+ }
+
+ $targetPath = $importsPath . DIRECTORY_SEPARATOR . $this->dumpFile;
+
+ if ( ! copy($fixturePath, $targetPath)) {
+ $this->fail('Unable to copy dump file to imports directory: ' . $targetPath);
+ }
+ }
+
+ #[Test]
+ public function it_imports_data_without_company_id_and_creates_new_company(): void
+ {
+ /* Arrange */
+ $initialCompanyCount = Company::count();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $this->assertEquals($initialCompanyCount + 1, Company::count());
+
+ $company = Company::latest('id')->first();
+ $this->assertNotNull($company);
+ $this->assertStringContainsString('Imported from InvoicePlane v1', $company->name);
+ }
+
+ #[Test]
+ public function it_imports_data_into_existing_company(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+ $initialCompanyCount = Company::count();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $this->assertEquals($initialCompanyCount, Company::count());
+ }
+
+ #[Test]
+ public function it_imports_product_categories_correctly(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $categories = ProductCategory::where('company_id', $company->id)->get();
+ $this->assertGreaterThanOrEqual(2, $categories->count());
+
+ $servicesCategory = $categories->where('category_name', 'Services')->first();
+ $this->assertNotNull($servicesCategory);
+ $this->assertEquals($company->id, $servicesCategory->company_id);
+ }
+
+ #[Test]
+ public function it_imports_product_units_correctly(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $units = ProductUnit::where('company_id', $company->id)->get();
+ $this->assertGreaterThanOrEqual(2, $units->count());
+
+ $hourUnit = $units->where('unit_name', 'Hour')->first();
+ $this->assertNotNull($hourUnit);
+ $this->assertEquals('Hours', $hourUnit->unit_name_plrl);
+ }
+
+ #[Test]
+ public function it_imports_products_with_relationships(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $products = Product::where('company_id', $company->id)->get();
+ $this->assertGreaterThanOrEqual(2, $products->count());
+
+ $consulting = $products->where('product_name', 'Consulting')->first();
+ $this->assertNotNull($consulting);
+ $this->assertEquals('SRV001', $consulting->code);
+ $this->assertEquals(100.00, $consulting->price);
+ $this->assertNotNull($consulting->category_id);
+ $this->assertNotNull($consulting->unit_id);
+ }
+
+ #[Test]
+ public function it_imports_clients_as_relations(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $relations = Relation::where('company_id', $company->id)->get();
+ $this->assertGreaterThanOrEqual(2, $relations->count());
+
+ $client = $relations->where('company_name', 'Test Client 1')->first();
+ $this->assertNotNull($client);
+ $this->assertEquals('customer', $client->relation_type->value);
+ $this->assertEquals('VAT123456', $client->vat_number);
+ $this->assertEquals('active', $client->relation_status->value);
+ }
+
+ #[Test]
+ public function it_imports_invoice_groups_as_numbering(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $numbering = Numbering::where('company_id', $company->id)
+ ->where('type', 'invoice')
+ ->get();
+
+ $this->assertGreaterThanOrEqual(1, $numbering->count());
+
+ $defaultGroup = $numbering->where('name', 'Default')->first();
+ $this->assertNotNull($defaultGroup);
+ $this->assertEquals('INV', $defaultGroup->prefix);
+ $this->assertEquals(1001, $defaultGroup->next_id);
+ }
+
+ #[Test]
+ public function it_imports_invoices_with_items(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $invoices = Invoice::where('company_id', $company->id)->get();
+ $this->assertGreaterThanOrEqual(2, $invoices->count());
+
+ $invoice = $invoices->where('invoice_number', 'INV-001')->first();
+ $this->assertNotNull($invoice);
+ $this->assertNotNull($invoice->customer_id);
+ $this->assertEquals('sent', $invoice->invoice_status->value);
+ $this->assertEquals(100.00, $invoice->invoice_item_subtotal);
+ $this->assertEquals(21.00, $invoice->invoice_tax_total);
+ $this->assertEquals(121.00, $invoice->invoice_total);
+
+ // Check invoice items
+ $items = InvoiceItem::where('company_id', $company->id)
+ ->where('invoice_id', $invoice->id)
+ ->get();
+
+ $this->assertGreaterThanOrEqual(1, $items->count());
+
+ $item = $items->first();
+ $this->assertEquals('Consulting', $item->item_name);
+ $this->assertEquals(1.00, $item->quantity);
+ $this->assertEquals(100.00, $item->price);
+ }
+
+ #[Test]
+ public function it_imports_quotes_with_items(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $quotes = Quote::where('company_id', $company->id)->get();
+ $this->assertGreaterThanOrEqual(1, $quotes->count());
+
+ $quote = $quotes->where('quote_number', 'QUO-001')->first();
+ $this->assertNotNull($quote);
+ $this->assertNotNull($quote->prospect_id);
+ $this->assertEquals('sent', $quote->quote_status->value);
+ $this->assertEquals(100.00, $quote->quote_item_subtotal);
+
+ // Check quote items
+ $items = QuoteItem::where('company_id', $company->id)
+ ->where('quote_id', $quote->id)
+ ->get();
+
+ $this->assertGreaterThanOrEqual(1, $items->count());
+ }
+
+ #[Test]
+ public function it_imports_payments_correctly(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $payments = Payment::where('company_id', $company->id)->get();
+ $this->assertGreaterThanOrEqual(1, $payments->count());
+
+ $payment = $payments->where('payment_amount', 54.50)->first();
+ $this->assertNotNull($payment);
+ $this->assertNotNull($payment->invoice_id);
+ $this->assertNotNull($payment->customer_id);
+ $this->assertEquals(PaymentMethod::BANK_TRANSFER, $payment->payment_method);
+ $this->assertEquals(54.50, $payment->payment_amount);
+ $this->assertEquals(PaymentStatus::COMPLETED, $payment->payment_status);
+ }
+
+ #[Test]
+ public function it_returns_failure_when_dump_file_not_found(): void
+ {
+ /* Arrange */
+ $nonExistentFile = '/tmp/non_existent_dump.sql';
+
+ /* Act & Assert */
+ $this->artisan('import:db', [
+ 'filename' => $nonExistentFile,
+ ])->assertFailed();
+ }
+
+ #[Test]
+ public function it_maintains_data_relationships(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])->assertSuccessful();
+
+ /* Assert */
+ $invoice = Invoice::where('company_id', $company->id)
+ ->where('invoice_number', 'INV-001')
+ ->first();
+
+ $this->assertNotNull($invoice);
+ $this->assertInstanceOf(Relation::class, $invoice->customer);
+ $this->assertEquals('Test Client 1', $invoice->customer->company_name);
+
+ // Check invoice items have products
+ $invoiceItem = InvoiceItem::where('invoice_id', $invoice->id)->first();
+ $this->assertNotNull($invoiceItem);
+ $this->assertInstanceOf(Product::class, $invoiceItem->product);
+ $this->assertEquals('Consulting', $invoiceItem->product->product_name);
+ }
+
+ #[Test]
+ public function it_shows_import_statistics(): void
+ {
+ /* Arrange */
+ $company = Company::factory()->create();
+
+ /* Act */
+ $this->artisan('import:db', [
+ 'filename' => $this->dumpFile,
+ '--company_id' => $company->id,
+ ])
+ ->expectsOutputToContain('Import completed successfully!')
+ ->expectsOutputToContain('Product Categories')
+ ->expectsOutputToContain('Products')
+ ->expectsOutputToContain('Clients')
+ ->expectsOutputToContain('Invoices')
+ ->expectsOutputToContain('Payments')
+ ->assertSuccessful();
+ }
+}
diff --git a/Modules/Core/Tests/Unit/Services/Import/ClientsImportServiceTest.php b/Modules/Core/Tests/Unit/Services/Import/ClientsImportServiceTest.php
new file mode 100644
index 000000000..3ea83c745
--- /dev/null
+++ b/Modules/Core/Tests/Unit/Services/Import/ClientsImportServiceTest.php
@@ -0,0 +1,273 @@
+getPdo();
+ } catch (Throwable $e) {
+ $this->markTestSkipped('import_v1 database connection unavailable');
+ }
+
+ $this->service = new ClientsImportService();
+ $this->company = Company::factory()->create();
+ $this->idMappings = ['clients' => []];
+
+ DB::purge('import_v1');
+
+ $this->setupImportDatabase();
+ }
+
+ protected function tearDown(): void
+ {
+ try {
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_clients');
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_contacts');
+ } catch (Throwable) {
+ // connection unavailable — nothing to drop
+ }
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function it_imports_clients_as_relations_successfully(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_clients')->insert([
+ [
+ 'client_id' => 1,
+ 'client_name' => 'Test Client 1',
+ 'client_vat_id' => 'VAT123',
+ 'client_active' => 1,
+ 'client_address_1' => null,
+ 'client_address_2' => null,
+ 'client_city' => null,
+ 'client_state' => null,
+ 'client_zip' => null,
+ 'client_country' => null,
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(1, $stats['clients']);
+ $this->assertEquals(1, Relation::where('company_id', $this->company->id)->count());
+
+ $relation = Relation::where('company_id', $this->company->id)->first();
+ $this->assertEquals('Test Client 1', $relation->company_name);
+ $this->assertEquals('VAT123', $relation->vat_number);
+ $this->assertEquals('active', $relation->relation_status->value);
+ $this->assertEquals('customer', $relation->relation_type->value);
+ }
+
+ #[Test]
+ public function it_creates_addresses_for_clients_with_address_data(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_clients')->insert([
+ [
+ 'client_id' => 1,
+ 'client_name' => 'Test Client',
+ 'client_vat_id' => null,
+ 'client_active' => 1,
+ 'client_address_1' => '123 Main St',
+ 'client_address_2' => 'Suite 100',
+ 'client_city' => 'New York',
+ 'client_state' => 'NY',
+ 'client_zip' => '10001',
+ 'client_country' => 'US',
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(1, $stats['addresses']);
+
+ $address = Address::where('company_id', $this->company->id)->first();
+ $this->assertNotNull($address);
+ $this->assertEquals('123 Main St', $address->address_1);
+ $this->assertEquals('New York', $address->city);
+ $this->assertEquals('10001', $address->postal_code);
+ }
+
+ #[Test]
+ public function it_does_not_create_address_when_no_address_data(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_clients')->insert([
+ [
+ 'client_id' => 1,
+ 'client_name' => 'Test Client',
+ 'client_vat_id' => null,
+ 'client_active' => 1,
+ 'client_address_1' => null,
+ 'client_address_2' => null,
+ 'client_city' => null,
+ 'client_state' => null,
+ 'client_zip' => null,
+ 'client_country' => null,
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(0, $stats['addresses']);
+ $this->assertEquals(0, Address::where('company_id', $this->company->id)->count());
+ }
+
+ #[Test]
+ public function it_imports_contacts_successfully(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_clients')->insert([
+ [
+ 'client_id' => 1,
+ 'client_name' => 'Test Client',
+ 'client_vat_id' => null,
+ 'client_active' => 1,
+ 'client_address_1' => null,
+ 'client_address_2' => null,
+ 'client_city' => null,
+ 'client_state' => null,
+ 'client_zip' => null,
+ 'client_country' => null,
+ ],
+ ]);
+
+ DB::connection('import_v1')->table('ip_contacts')->insert([
+ [
+ 'contact_id' => 1,
+ 'client_id' => 1,
+ 'contact_name' => 'John Doe',
+ 'contact_email' => 'john@example.com',
+ 'contact_phone' => '555-1234',
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(1, $stats['contacts']);
+ $this->assertEquals(2, $stats['communications']); // email + phone
+
+ $contact = Contact::where('company_id', $this->company->id)->first();
+ $this->assertNotNull($contact);
+ $this->assertEquals('John', $contact->first_name);
+ $this->assertEquals('Doe', $contact->last_name);
+ $this->assertEquals('John Doe', $contact->full_name);
+ }
+
+ #[Test]
+ public function it_skips_contacts_for_non_existent_clients(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_contacts')->insert([
+ [
+ 'contact_id' => 1,
+ 'client_id' => 999, // Non-existent
+ 'contact_name' => 'John Doe',
+ 'contact_email' => 'john@example.com',
+ 'contact_phone' => '555-1234',
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(0, $stats['contacts']);
+ }
+
+ #[Test]
+ public function it_handles_inactive_clients(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_clients')->insert([
+ [
+ 'client_id' => 1,
+ 'client_name' => 'Inactive Client',
+ 'client_vat_id' => null,
+ 'client_active' => 0,
+ 'client_address_1' => null,
+ 'client_address_2' => null,
+ 'client_city' => null,
+ 'client_state' => null,
+ 'client_zip' => null,
+ 'client_country' => null,
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $relation = Relation::where('company_id', $this->company->id)->first();
+ $this->assertEquals('inactive', $relation->relation_status->value);
+ }
+
+ private function setupImportDatabase(): void
+ {
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_clients');
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_contacts');
+
+ DB::connection('import_v1')->statement('
+ CREATE TABLE ip_clients (
+ client_id INT PRIMARY KEY,
+ client_name VARCHAR(255),
+ client_vat_id VARCHAR(255),
+ client_active TINYINT,
+ client_address_1 VARCHAR(255),
+ client_address_2 VARCHAR(255),
+ client_city VARCHAR(255),
+ client_state VARCHAR(255),
+ client_zip VARCHAR(255),
+ client_country VARCHAR(255)
+ )
+ ');
+
+ DB::connection('import_v1')->statement('
+ CREATE TABLE ip_contacts (
+ contact_id INT PRIMARY KEY,
+ client_id INT,
+ contact_name VARCHAR(255),
+ contact_email VARCHAR(255),
+ contact_phone VARCHAR(255)
+ )
+ ');
+ }
+}
diff --git a/Modules/Core/Tests/Unit/Services/Import/ProductsImportServiceTest.php b/Modules/Core/Tests/Unit/Services/Import/ProductsImportServiceTest.php
new file mode 100644
index 000000000..d62d4bd79
--- /dev/null
+++ b/Modules/Core/Tests/Unit/Services/Import/ProductsImportServiceTest.php
@@ -0,0 +1,229 @@
+getPdo();
+ } catch (Throwable $e) {
+ $this->markTestSkipped('import_v1 database connection unavailable');
+ }
+
+ $this->service = new ProductsImportService();
+ $this->company = Company::factory()->create();
+ $this->idMappings = ['tax_rates' => [], 'product_families' => [], 'product_units' => []];
+
+ DB::purge('import_v1');
+
+ $this->setupImportDatabase();
+ }
+
+ protected function tearDown(): void
+ {
+ try {
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_families');
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_units');
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_products');
+ } catch (Throwable) {
+ // connection unavailable — nothing to drop
+ }
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function it_imports_product_categories_successfully(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_families')->insert([
+ ['family_id' => 1, 'family_name' => 'Services'],
+ ['family_id' => 2, 'family_name' => 'Products'],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(2, $stats['product_categories']);
+ $this->assertDatabaseHas('product_categories', ['company_id' => $this->company->id, 'category_name' => 'Services']);
+ $this->assertDatabaseHas('product_categories', ['company_id' => $this->company->id, 'category_name' => 'Products']);
+ $this->assertArrayHasKey(1, $this->idMappings['product_families']);
+ $this->assertArrayHasKey(2, $this->idMappings['product_families']);
+ }
+
+ #[Test]
+ public function it_imports_product_units_successfully(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_units')->insert([
+ ['unit_id' => 1, 'unit_name' => 'Hour', 'unit_name_plrl' => 'Hours'],
+ ['unit_id' => 2, 'unit_name' => 'Piece', 'unit_name_plrl' => 'Pieces'],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(2, $stats['product_units']);
+ $this->assertDatabaseHas('product_units', ['company_id' => $this->company->id, 'unit_name' => 'Hour']);
+ $this->assertDatabaseHas('product_units', ['company_id' => $this->company->id, 'unit_name' => 'Piece']);
+ }
+
+ #[Test]
+ public function it_imports_products_with_all_relationships(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_families')->insert([
+ ['family_id' => 1, 'family_name' => 'Services'],
+ ]);
+ DB::connection('import_v1')->table('ip_units')->insert([
+ ['unit_id' => 1, 'unit_name' => 'Hour', 'unit_name_plrl' => 'Hours'],
+ ]);
+ DB::connection('import_v1')->table('ip_products')->insert([
+ [
+ 'product_id' => 1,
+ 'family_id' => 1,
+ 'unit_id' => 1,
+ 'tax_rate_id' => null,
+ 'product_sku' => 'SRV001',
+ 'product_name' => 'Consulting',
+ 'product_description' => 'Hourly consulting',
+ 'product_price' => 100.00,
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(1, $stats['products']);
+
+ $product = Product::where('company_id', $this->company->id)->first();
+ $this->assertNotNull($product);
+ $this->assertEquals('Consulting', $product->product_name);
+ $this->assertEquals('SRV001', $product->code);
+ $this->assertEquals(100.00, $product->price);
+ $this->assertNotNull($product->category_id);
+ $this->assertNotNull($product->unit_id);
+ }
+
+ #[Test]
+ public function it_creates_default_category_when_family_not_found(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_products')->insert([
+ [
+ 'product_id' => 1,
+ 'family_id' => 999, // Non-existent
+ 'unit_id' => null,
+ 'tax_rate_id' => null,
+ 'product_sku' => null,
+ 'product_name' => 'Test Product',
+ 'product_description' => null,
+ 'product_price' => 50.00,
+ ],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(1, $stats['products']);
+
+ $product = Product::where('company_id', $this->company->id)->first();
+ $this->assertNotNull($product);
+
+ $defaultCategory = ProductCategory::where('company_id', $this->company->id)
+ ->where('category_name', 'Default')
+ ->first();
+ $this->assertNotNull($defaultCategory);
+ $this->assertEquals($defaultCategory->id, $product->category_id);
+ }
+
+ #[Test]
+ public function it_handles_unit_name_plural_fallback(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_units')->insert([
+ ['unit_id' => 1, 'unit_name' => 'Item', 'unit_name_plrl' => null],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $unit = ProductUnit::where('company_id', $this->company->id)->where('unit_name', 'Item')->first();
+ $this->assertNotNull($unit);
+ $this->assertEquals('Item', $unit->unit_name_plrl);
+ }
+
+ #[Test]
+ public function it_returns_correct_table_list(): void
+ {
+ /* Assert */
+ $expected = ['ip_families', 'ip_units', 'ip_products'];
+ $this->assertEquals($expected, $this->service->getTables());
+ }
+
+ private function setupImportDatabase(): void
+ {
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_families');
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_units');
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_products');
+
+ DB::connection('import_v1')->statement('
+ CREATE TABLE ip_families (
+ family_id INT PRIMARY KEY,
+ family_name VARCHAR(255)
+ )
+ ');
+
+ DB::connection('import_v1')->statement('
+ CREATE TABLE ip_units (
+ unit_id INT PRIMARY KEY,
+ unit_name VARCHAR(255),
+ unit_name_plrl VARCHAR(255)
+ )
+ ');
+
+ DB::connection('import_v1')->statement('
+ CREATE TABLE ip_products (
+ product_id INT PRIMARY KEY,
+ family_id INT,
+ unit_id INT,
+ tax_rate_id INT,
+ product_sku VARCHAR(255),
+ product_name VARCHAR(255),
+ product_description TEXT,
+ product_price DECIMAL(20,4)
+ )
+ ');
+ }
+}
diff --git a/Modules/Core/Tests/Unit/Services/Import/TaxRatesImportServiceTest.php b/Modules/Core/Tests/Unit/Services/Import/TaxRatesImportServiceTest.php
new file mode 100644
index 000000000..681992bed
--- /dev/null
+++ b/Modules/Core/Tests/Unit/Services/Import/TaxRatesImportServiceTest.php
@@ -0,0 +1,168 @@
+getPdo();
+ } catch (Throwable $e) {
+ $this->markTestSkipped('import_v1 database connection unavailable');
+ }
+
+ $this->service = new TaxRatesImportService();
+ $this->company = Company::factory()->create();
+
+ DB::purge('import_v1');
+
+ $this->setupImportDatabase();
+ }
+
+ protected function tearDown(): void
+ {
+ try {
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_tax_rates');
+ } catch (Throwable) {
+ // connection unavailable — nothing to drop
+ }
+ parent::tearDown();
+ }
+
+ #[Test]
+ public function it_imports_tax_rates_successfully(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_tax_rates')->insert([
+ ['tax_rate_id' => 1, 'tax_rate_name' => 'VAT 21%', 'tax_rate_percent' => 21.000],
+ ['tax_rate_id' => 2, 'tax_rate_name' => 'VAT 9%', 'tax_rate_percent' => 9.000],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(2, $stats['tax_rates']);
+ $this->assertDatabaseHas('tax_rates', ['company_id' => $this->company->id, 'name' => 'VAT 21%']);
+ $this->assertDatabaseHas('tax_rates', ['company_id' => $this->company->id, 'name' => 'VAT 9%']);
+
+ $taxRate1 = TaxRate::where('company_id', $this->company->id)
+ ->where('name', 'VAT 21%')
+ ->first();
+ $this->assertNotNull($taxRate1);
+ $this->assertEquals(21.000, $taxRate1->rate);
+ $this->assertArrayHasKey(1, $this->idMappings['tax_rates']);
+ }
+
+ #[Test]
+ public function it_handles_missing_tax_rate_name_with_default(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_tax_rates')->insert([
+ ['tax_rate_id' => 1, 'tax_rate_name' => null, 'tax_rate_percent' => 21.000],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(1, $stats['tax_rates']);
+ $taxRate = TaxRate::where('company_id', $this->company->id)->where('name', 'Tax')->first();
+ $this->assertNotNull($taxRate);
+ $this->assertEquals('Tax', $taxRate->name);
+ }
+
+ #[Test]
+ public function it_handles_missing_tax_rate_percent_with_zero(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_tax_rates')->insert([
+ ['tax_rate_id' => 1, 'tax_rate_name' => 'VAT', 'tax_rate_percent' => null],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(1, $stats['tax_rates']);
+ $taxRate = TaxRate::where('company_id', $this->company->id)->where('name', 'VAT')->first();
+ $this->assertNotNull($taxRate);
+ $this->assertEquals(0, $taxRate->rate);
+ }
+
+ #[Test]
+ public function it_handles_empty_table_gracefully(): void
+ {
+ /* Arrange */
+ // Table exists but is empty
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(0, $stats['tax_rates']);
+ }
+
+ #[Test]
+ public function it_avoids_duplicate_tax_rates(): void
+ {
+ /* Arrange */
+ DB::connection('import_v1')->table('ip_tax_rates')->insert([
+ ['tax_rate_id' => 1, 'tax_rate_name' => 'VAT 21%', 'tax_rate_percent' => 21.000],
+ ['tax_rate_id' => 2, 'tax_rate_name' => 'VAT 21%', 'tax_rate_percent' => 21.000],
+ ]);
+
+ /* Act */
+ $stats = $this->service->import($this->company->id, $this->idMappings);
+
+ /* Assert */
+ $this->assertEquals(2, $stats['tax_rates']);
+ // Should create only 1 unique tax rate due to firstOrCreate
+ $this->assertEquals(1, TaxRate::where('company_id', $this->company->id)
+ ->where('name', 'VAT 21%')
+ ->count());
+ }
+
+ #[Test]
+ public function it_returns_correct_table_list(): void
+ {
+ /* Assert */
+ $this->assertEquals(['ip_tax_rates'], $this->service->getTables());
+ }
+
+ private function setupImportDatabase(): void
+ {
+ DB::connection('import_v1')->statement('DROP TABLE IF EXISTS ip_tax_rates');
+ DB::connection('import_v1')->statement('
+ CREATE TABLE ip_tax_rates (
+ tax_rate_id INT PRIMARY KEY,
+ tax_rate_name VARCHAR(255),
+ tax_rate_percent DECIMAL(8,3)
+ )
+ ');
+ }
+}
diff --git a/Modules/Core/Tests/Unit/Services/ImportInvoicePlaneV1ServiceTest.php b/Modules/Core/Tests/Unit/Services/ImportInvoicePlaneV1ServiceTest.php
new file mode 100644
index 000000000..58ae4aa55
--- /dev/null
+++ b/Modules/Core/Tests/Unit/Services/ImportInvoicePlaneV1ServiceTest.php
@@ -0,0 +1,38 @@
+service = new ImportInvoicePlaneV1Service();
+ }
+
+ #[Test]
+ public function it_can_be_instantiated(): void
+ {
+ /* Assert */
+ $this->assertInstanceOf(ImportInvoicePlaneV1Service::class, $this->service);
+ }
+
+ #[Test]
+ public function it_has_correct_temp_database_name(): void
+ {
+ /* Arrange */
+ $reflection = new ReflectionClass($this->service);
+ $constant = $reflection->getConstant('TEMP_DB_NAME');
+
+ /* Assert */
+ $this->assertEquals('invoiceplane_v1_temp', $constant);
+ }
+}
diff --git a/Modules/Expenses/Exports/ExpensesExport.php b/Modules/Expenses/Exports/ExpensesExport.php
new file mode 100644
index 000000000..e027e0dd2
--- /dev/null
+++ b/Modules/Expenses/Exports/ExpensesExport.php
@@ -0,0 +1,49 @@
+expenses = $expenses;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->expenses;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.expense_status'),
+ trans('ip.expense_category'),
+ trans('ip.expense_type'),
+ trans('ip.expense_number'),
+ trans('ip.vendor'),
+ trans('ip.expensed_at'),
+ trans('ip.expense_amount'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->expense_status?->label() ?? '',
+ $row->expenseCategory?->category_name,
+ $row->expense_type?->label() ?? '',
+ $row->expense_number,
+ $row->vendor?->company_name ?? '',
+ $row->expensed_at,
+ $row->expense_amount,
+ ];
+ }
+}
diff --git a/Modules/Expenses/Exports/ExpensesLegacyExport.php b/Modules/Expenses/Exports/ExpensesLegacyExport.php
new file mode 100644
index 000000000..4e848ca67
--- /dev/null
+++ b/Modules/Expenses/Exports/ExpensesLegacyExport.php
@@ -0,0 +1,41 @@
+expenses = $expenses;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->expenses;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.expense_category'),
+ trans('ip.expensed_at'),
+ trans('ip.amount'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->expenseCategory?->category_name,
+ $row->expensed_at,
+ $row->expense_amount,
+ ];
+ }
+}
diff --git a/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php b/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php
index 176fe7ce0..3ff8b2e02 100644
--- a/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php
+++ b/Modules/Expenses/Filament/Company/Resources/Expenses/Pages/ListExpenses.php
@@ -2,10 +2,15 @@
namespace Modules\Expenses\Filament\Company\Resources\Expenses\Pages;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
-use Modules\Core\Enums\Permission;
+use Filament\Support\Icons\Heroicon;
use Modules\Expenses\Filament\Company\Resources\Expenses\ExpenseResource;
+use Modules\Expenses\Filament\Exporters\ExpenseExporter;
+use Modules\Expenses\Filament\Exporters\ExpenseLegacyExporter;
use Modules\Expenses\Services\ExpenseService;
class ListExpenses extends ListRecords
@@ -24,6 +29,32 @@ protected function getHeaderActions(): array
app(ExpenseService::class)->createExpense($data);
})
->modalWidth('full'),
+
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ExpenseExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ExpenseLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(ExpenseExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(ExpenseLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Expenses/Filament/Exporters/ExpenseExporter.php b/Modules/Expenses/Filament/Exporters/ExpenseExporter.php
new file mode 100644
index 000000000..eb4beab02
--- /dev/null
+++ b/Modules/Expenses/Filament/Exporters/ExpenseExporter.php
@@ -0,0 +1,43 @@
+label(trans('ip.expense_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('expense_category')
+ ->label(trans('ip.expense_category'))
+ ->formatStateUsing(fn ($state, Expense $record) => $record->expenseCategory?->category_name ?? ''),
+ ExportColumn::make('expense_type')
+ ->label(trans('ip.expense_type'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('expense_number')
+ ->label(trans('ip.expense_number')),
+ ExportColumn::make('vendor')
+ ->label(trans('ip.vendor'))
+ ->formatStateUsing(fn ($state, Expense $record) => $record->vendor?->company_name ?? ''),
+ ExportColumn::make('expensed_at')
+ ->label(trans('ip.expensed_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('expense_amount')
+ ->label(trans('ip.expense_amount')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.expense');
+ }
+}
diff --git a/Modules/Expenses/Filament/Exporters/ExpenseLegacyExporter.php b/Modules/Expenses/Filament/Exporters/ExpenseLegacyExporter.php
new file mode 100644
index 000000000..3dfedad03
--- /dev/null
+++ b/Modules/Expenses/Filament/Exporters/ExpenseLegacyExporter.php
@@ -0,0 +1,32 @@
+label(trans('ip.expense_category'))
+ ->formatStateUsing(fn ($state, Expense $record) => $record->expenseCategory?->category_name ?? ''),
+ ExportColumn::make('expensed_at')
+ ->label(trans('ip.expensed_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('expense_amount')
+ ->label(trans('ip.amount')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.expense');
+ }
+}
diff --git a/Modules/Expenses/Services/ExpenseExportService.php b/Modules/Expenses/Services/ExpenseExportService.php
new file mode 100644
index 000000000..e51cd823f
--- /dev/null
+++ b/Modules/Expenses/Services/ExpenseExportService.php
@@ -0,0 +1,34 @@
+where('company_id', $companyId)->get();
+ $fileName = 'expenses-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $version = config('ip.export_version', 2);
+ $exportClass = $version === 1 ? ExpensesLegacyExport::class : ExpensesExport::class;
+
+ return Excel::download(new $exportClass($expenses), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $expenses = Expense::query()->where('company_id', $companyId)->get();
+ $fileName = 'expenses-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? ExpensesLegacyExport::class : ExpensesExport::class;
+
+ return Excel::download(new $exportClass($expenses), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Expenses/Tests/Feature/ExpensesExportImportTest.php b/Modules/Expenses/Tests/Feature/ExpensesExportImportTest.php
new file mode 100644
index 000000000..789025022
--- /dev/null
+++ b/Modules/Expenses/Tests/Feature/ExpensesExportImportTest.php
@@ -0,0 +1,207 @@
+for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'expense_status' => ['isEnabled' => true, 'label' => 'Status'],
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $expenses = Expense::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'expense_status' => ['isEnabled' => true, 'label' => 'Status'],
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_no_records(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ // No expenses created
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_special_characters(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $expense = Expense::factory()->for($this->company)->create([
+ 'description' => 'Üxpense, "Test"',
+ 'expense_amount' => 123.45,
+ ]);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v2_with_column_selection(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $expenses = Expense::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'expense_status' => ['isEnabled' => true, 'label' => 'Status'],
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ 'expense_amount' => ['isEnabled' => false, 'label' => 'Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $expenses = Expense::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2_with_data(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $expenses = Expense::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ 'expense_amount' => ['isEnabled' => true, 'label' => 'Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $expenses = Expense::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListExpenses::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'expense_number' => ['isEnabled' => true, 'label' => 'Number'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+}
diff --git a/Modules/Invoices/Exports/InvoicesExport.php b/Modules/Invoices/Exports/InvoicesExport.php
new file mode 100644
index 000000000..996c6bfef
--- /dev/null
+++ b/Modules/Invoices/Exports/InvoicesExport.php
@@ -0,0 +1,47 @@
+invoices = $invoices;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->invoices;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.invoice_status'),
+ trans('ip.invoice_number'),
+ trans('ip.customer_name'),
+ trans('ip.invoiced_at'),
+ trans('ip.invoice_due_at'),
+ trans('ip.invoice_total'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->invoice_status?->label() ?? '',
+ $row->invoice_number,
+ $row->customer?->trading_name ?? $row->customer?->company_name ?? '',
+ $row->invoiced_at,
+ $row->invoice_due_at,
+ $row->invoice_total,
+ ];
+ }
+}
diff --git a/Modules/Invoices/Exports/InvoicesLegacyExport.php b/Modules/Invoices/Exports/InvoicesLegacyExport.php
new file mode 100644
index 000000000..431319d38
--- /dev/null
+++ b/Modules/Invoices/Exports/InvoicesLegacyExport.php
@@ -0,0 +1,43 @@
+invoices = $invoices;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->invoices;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.invoice_status'),
+ trans('ip.invoice_number'),
+ trans('ip.customer_name'),
+ trans('ip.invoice_total'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->invoice_status?->label() ?? '',
+ $row->invoice_number,
+ $row->customer?->trading_name ?? $row->customer?->company_name ?? '',
+ $row->invoice_total,
+ ];
+ }
+}
diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php
index 2a22fb055..ee62de339 100644
--- a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php
+++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/ListInvoices.php
@@ -2,10 +2,15 @@
namespace Modules\Invoices\Filament\Company\Resources\Invoices\Pages;
-use Filament\Actions\Action;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource;
+use Modules\Invoices\Filament\Exporters\InvoiceExporter;
+use Modules\Invoices\Filament\Exporters\InvoiceLegacyExporter;
use Modules\Invoices\Services\InvoiceService;
class ListInvoices extends ListRecords
@@ -29,6 +34,31 @@ protected function getHeaderActions(): array
);
}
}),
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(InvoiceExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(InvoiceLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(InvoiceExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(InvoiceLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Invoices/Filament/Exporters/InvoiceExporter.php b/Modules/Invoices/Filament/Exporters/InvoiceExporter.php
new file mode 100644
index 000000000..cb0264efe
--- /dev/null
+++ b/Modules/Invoices/Filament/Exporters/InvoiceExporter.php
@@ -0,0 +1,40 @@
+label(trans('ip.invoice_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('invoice_number')
+ ->label(trans('ip.invoice_number')),
+ ExportColumn::make('customer_name')
+ ->label(trans('ip.customer_name'))
+ ->formatStateUsing(fn ($state, Invoice $record) => $record->customer?->trading_name ?? $record->customer?->company_name ?? ''),
+ ExportColumn::make('invoiced_at')
+ ->label(trans('ip.invoiced_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->format('Y-m-d') : null),
+ ExportColumn::make('invoice_due_at')
+ ->label(trans('ip.invoice_due_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->format('Y-m-d') : null),
+ ExportColumn::make('invoice_total')
+ ->label(trans('ip.invoice_total')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.invoice');
+ }
+}
diff --git a/Modules/Invoices/Filament/Exporters/InvoiceLegacyExporter.php b/Modules/Invoices/Filament/Exporters/InvoiceLegacyExporter.php
new file mode 100644
index 000000000..9795d9568
--- /dev/null
+++ b/Modules/Invoices/Filament/Exporters/InvoiceLegacyExporter.php
@@ -0,0 +1,33 @@
+label(trans('ip.invoice_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('invoice_number')
+ ->label(trans('ip.invoice_number')),
+ ExportColumn::make('customer_name')
+ ->label(trans('ip.customer_name'))
+ ->formatStateUsing(fn ($state, Invoice $record) => $record->customer?->trading_name ?? $record->customer?->company_name ?? ''),
+ ExportColumn::make('invoice_total')
+ ->label(trans('ip.invoice_total')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.invoice');
+ }
+}
diff --git a/Modules/Invoices/Services/InvoiceExportService.php b/Modules/Invoices/Services/InvoiceExportService.php
new file mode 100644
index 000000000..199e5b22f
--- /dev/null
+++ b/Modules/Invoices/Services/InvoiceExportService.php
@@ -0,0 +1,34 @@
+where('company_id', $companyId)->get();
+ $fileName = 'invoices-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $version = config('ip.export_version', 2);
+ $exportClass = $version === 1 ? InvoicesLegacyExport::class : InvoicesExport::class;
+
+ return Excel::download(new $exportClass($invoices), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $invoices = Invoice::query()->where('company_id', $companyId)->get();
+ $fileName = 'invoices-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? InvoicesLegacyExport::class : InvoicesExport::class;
+
+ return Excel::download(new $exportClass($invoices), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Invoices/Tests/Feature/InvoicesExportImportTest.php b/Modules/Invoices/Tests/Feature/InvoicesExportImportTest.php
new file mode 100644
index 000000000..39e4e6a2a
--- /dev/null
+++ b/Modules/Invoices/Tests/Feature/InvoicesExportImportTest.php
@@ -0,0 +1,139 @@
+markTestSkipped('exportCsv action does not exist; only exportCsvV1/V2 are registered');
+ }
+
+ #[Test]
+ #[Group('export')]
+ #[Group('failing')]
+ public function it_dispatches_excel_export_job(): void
+ {
+ $this->markTestSkipped('exportExcel action does not exist; only exportExcelV1/V2 are registered');
+ }
+
+ #[Test]
+ #[Group('export')]
+ #[Group('failing')]
+ public function it_exports_with_no_records(): void
+ {
+ $this->markTestSkipped('exportExcel action does not exist; only exportExcelV1/V2 are registered');
+ }
+
+ #[Test]
+ #[Group('export')]
+ #[Group('failing')]
+ public function it_exports_with_special_characters(): void
+ {
+ $this->markTestSkipped('exportExcel action does not exist; only exportExcelV1/V2 are registered');
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $invoices = Invoice::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListInvoices::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'number' => ['isEnabled' => true, 'label' => 'Number'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $invoices = Invoice::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListInvoices::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'number' => ['isEnabled' => true, 'label' => 'Number'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $invoices = Invoice::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListInvoices::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'number' => ['isEnabled' => true, 'label' => 'Number'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $invoices = Invoice::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListInvoices::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'number' => ['isEnabled' => true, 'label' => 'Number'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+}
diff --git a/Modules/Invoices/Tests/Feature/InvoicesTest.php b/Modules/Invoices/Tests/Feature/InvoicesTest.php
index 500e6b59d..6167443d2 100644
--- a/Modules/Invoices/Tests/Feature/InvoicesTest.php
+++ b/Modules/Invoices/Tests/Feature/InvoicesTest.php
@@ -11,10 +11,6 @@
use Modules\Clients\Enums\CommunicationType;
use Modules\Clients\Models\Relation;
use Modules\Core\Enums\NumberingType;
-use Modules\Core\Enums\Permission;
-use Modules\Core\Models\Company;
-use Modules\Core\Models\EmailTemplate;
-use Modules\Core\Models\NoteTemplate;
use Modules\Core\Models\Numbering;
use Modules\Core\Models\Setting;
use Modules\Core\Models\TaxRate;
diff --git a/Modules/Payments/Exports/PaymentsExport.php b/Modules/Payments/Exports/PaymentsExport.php
new file mode 100644
index 000000000..c8c56c057
--- /dev/null
+++ b/Modules/Payments/Exports/PaymentsExport.php
@@ -0,0 +1,45 @@
+payments = $payments;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->payments;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.payment_method'),
+ trans('ip.payment_status'),
+ trans('ip.customer_name'),
+ trans('ip.payment_amount'),
+ trans('ip.paid_at'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->payment_method?->label() ?? '',
+ $row->payment_status?->label() ?? '',
+ $row->customer?->trading_name ?? $row->customer?->company_name ?? '',
+ $row->payment_amount,
+ $row->paid_at,
+ ];
+ }
+}
diff --git a/Modules/Payments/Exports/PaymentsLegacyExport.php b/Modules/Payments/Exports/PaymentsLegacyExport.php
new file mode 100644
index 000000000..60ee439d1
--- /dev/null
+++ b/Modules/Payments/Exports/PaymentsLegacyExport.php
@@ -0,0 +1,43 @@
+payments = $payments;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->payments;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.payment_method'),
+ trans('ip.payment_status'),
+ trans('ip.payment_amount'),
+ trans('ip.paid_at'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->payment_method?->label() ?? '',
+ $row->payment_status?->label() ?? '',
+ $row->payment_amount,
+ $row->paid_at,
+ ];
+ }
+}
diff --git a/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php b/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php
index 833dde53b..b31213a0d 100644
--- a/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php
+++ b/Modules/Payments/Filament/Company/Resources/Payments/Pages/ListPayments.php
@@ -2,9 +2,15 @@
namespace Modules\Payments\Filament\Company\Resources\Payments\Pages;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Modules\Payments\Filament\Company\Resources\Payments\PaymentResource;
+use Modules\Payments\Filament\Exporters\PaymentExporter;
+use Modules\Payments\Filament\Exporters\PaymentLegacyExporter;
use Modules\Payments\Services\PaymentService;
class ListPayments extends ListRecords
@@ -22,6 +28,32 @@ protected function getHeaderActions(): array
app(PaymentService::class)->createPayment($data);
})
->modalWidth('full'),
+
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(PaymentExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(PaymentLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(PaymentExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(PaymentLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Payments/Filament/Exporters/PaymentExporter.php b/Modules/Payments/Filament/Exporters/PaymentExporter.php
new file mode 100644
index 000000000..08a62cd6b
--- /dev/null
+++ b/Modules/Payments/Filament/Exporters/PaymentExporter.php
@@ -0,0 +1,38 @@
+label(trans('ip.payment_method'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('payment_status')
+ ->label(trans('ip.payment_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('customer_name')
+ ->label(trans('ip.customer_name'))
+ ->formatStateUsing(fn ($state, Payment $record) => $record->customer?->trading_name ?? $record->customer?->company_name ?? ''),
+ ExportColumn::make('payment_amount')
+ ->label(trans('ip.payment_amount')),
+ ExportColumn::make('paid_at')
+ ->label(trans('ip.paid_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.payment');
+ }
+}
diff --git a/Modules/Payments/Filament/Exporters/PaymentLegacyExporter.php b/Modules/Payments/Filament/Exporters/PaymentLegacyExporter.php
new file mode 100644
index 000000000..adbc218b5
--- /dev/null
+++ b/Modules/Payments/Filament/Exporters/PaymentLegacyExporter.php
@@ -0,0 +1,35 @@
+label(trans('ip.payment_method'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('payment_status')
+ ->label(trans('ip.payment_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('payment_amount')
+ ->label(trans('ip.payment_amount')),
+ ExportColumn::make('paid_at')
+ ->label(trans('ip.paid_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.payment');
+ }
+}
diff --git a/Modules/Payments/Services/PaymentExportService.php b/Modules/Payments/Services/PaymentExportService.php
new file mode 100644
index 000000000..f6fcf06a4
--- /dev/null
+++ b/Modules/Payments/Services/PaymentExportService.php
@@ -0,0 +1,76 @@
+validateCompanyContext();
+
+ $companyId = session('current_company_id');
+ $payments = $this->getPayments($companyId);
+ $version = config('ip.export_version', 2);
+
+ return $this->downloadExport($payments, $format, $version);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $this->validateCompanyContext();
+
+ $companyId = session('current_company_id');
+ $payments = Payment::query()->where('company_id', $companyId)->get();
+
+ return $this->downloadExport($payments, $format, $version);
+ }
+
+ protected function validateCompanyContext(): void
+ {
+ if ( ! session('current_company_id')) {
+ abort(403, 'No company context available');
+ }
+ }
+
+ protected function getPayments(int $companyId)
+ {
+ return Payment::query()
+ ->where('company_id', $companyId)
+ ->orderBy('paid_at', 'desc')
+ ->limit(10000)
+ ->get();
+ }
+
+ protected function downloadExport($payments, string $format, int $version): BinaryFileResponse
+ {
+ $fileName = $this->generateFileName($format);
+ $exportClass = $this->getExportClass($version);
+ $excelFormat = $this->getExcelFormat($format);
+
+ return Excel::download(new $exportClass($payments), $fileName, $excelFormat);
+ }
+
+ protected function generateFileName(string $format): string
+ {
+ $extension = $format === 'csv' ? 'csv' : 'xlsx';
+
+ return 'payments-' . now()->format('Y-m-d_H-i-s') . '.' . $extension;
+ }
+
+ protected function getExportClass(int $version): string
+ {
+ return $version === 1 ? PaymentsLegacyExport::class : PaymentsExport::class;
+ }
+
+ protected function getExcelFormat(string $format): string
+ {
+ return $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX;
+ }
+}
diff --git a/Modules/Payments/Tests/Feature/PaymentsExportImportTest.php b/Modules/Payments/Tests/Feature/PaymentsExportImportTest.php
new file mode 100644
index 000000000..68f6a6134
--- /dev/null
+++ b/Modules/Payments/Tests/Feature/PaymentsExportImportTest.php
@@ -0,0 +1,175 @@
+createPayments(3);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListPayments::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'payment_amount' => ['isEnabled' => true, 'label' => 'Payment Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $payments = $this->createPayments(3);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListPayments::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'payment_amount' => ['isEnabled' => true, 'label' => 'Payment Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_no_records(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ // No payments created
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListPayments::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'payment_amount' => ['isEnabled' => true, 'label' => 'Payment Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_special_characters(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $payment = $this->createPayments(1, [
+ 'payment_amount' => 123.45,
+ 'notes' => 'Ü Payment, "Test"',
+ ])->first();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListPayments::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'payment_amount' => ['isEnabled' => true, 'label' => 'Payment Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $payments = $this->createPayments(3);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListPayments::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'payment_amount' => ['isEnabled' => true, 'label' => 'Payment Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $payments = $this->createPayments(3);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListPayments::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'payment_amount' => ['isEnabled' => true, 'label' => 'Payment Amount'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ /**
+ * payments.invoice_id is NOT NULL, so every payment needs an invoice.
+ */
+ private function createPayments(int $count, array $attributes = [])
+ {
+ $customer = Relation::factory()->for($this->company)->customer()->create();
+ $invoice = Invoice::factory()->for($this->company)->create([
+ 'customer_id' => $customer->id,
+ 'numbering_id' => Numbering::factory()->for($this->company)->create()->id,
+ 'user_id' => $this->user->id,
+ ]);
+
+ return Payment::factory()->for($this->company)->count($count)->create(array_merge([
+ 'customer_id' => $customer->id,
+ 'invoice_id' => $invoice->id,
+ ], $attributes));
+ }
+}
diff --git a/Modules/Products/Exports/ProductsExport.php b/Modules/Products/Exports/ProductsExport.php
new file mode 100644
index 000000000..0d732d339
--- /dev/null
+++ b/Modules/Products/Exports/ProductsExport.php
@@ -0,0 +1,49 @@
+products = $products;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->products;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.category_name'),
+ trans('ip.product_unit'),
+ trans('ip.product_sku'),
+ trans('ip.product_name'),
+ trans('ip.product_type'),
+ trans('ip.product_price'),
+ trans('ip.cost_price'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->productCategory?->category_name,
+ $row->productUnit?->unit_name,
+ $row->code,
+ $row->product_name,
+ $row->type?->label() ?? '',
+ $row->price,
+ $row->cost_price,
+ ];
+ }
+}
diff --git a/Modules/Products/Exports/ProductsLegacyExport.php b/Modules/Products/Exports/ProductsLegacyExport.php
new file mode 100644
index 000000000..12b6e29f5
--- /dev/null
+++ b/Modules/Products/Exports/ProductsLegacyExport.php
@@ -0,0 +1,41 @@
+products = $products;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->products;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.product_sku'),
+ trans('ip.product_name'),
+ trans('ip.product_price'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->code,
+ $row->product_name,
+ $row->price,
+ ];
+ }
+}
diff --git a/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php b/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php
index 756488e8d..c5973e0c3 100644
--- a/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php
+++ b/Modules/Products/Filament/Company/Resources/Products/Pages/ListProducts.php
@@ -2,9 +2,15 @@
namespace Modules\Products\Filament\Company\Resources\Products\Pages;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Modules\Products\Filament\Company\Resources\Products\ProductResource;
+use Modules\Products\Filament\Exporters\ProductExporter;
+use Modules\Products\Filament\Exporters\ProductLegacyExporter;
use Modules\Products\Services\ProductService;
class ListProducts extends ListRecords
@@ -21,6 +27,32 @@ protected function getHeaderActions(): array
->action(function (array $data) {
app(ProductService::class)->createProduct($data);
})->modalWidth('full'),
+
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ProductExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ProductLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(ProductExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(ProductLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Products/Filament/Exporters/ProductExporter.php b/Modules/Products/Filament/Exporters/ProductExporter.php
new file mode 100644
index 000000000..8249a4a4d
--- /dev/null
+++ b/Modules/Products/Filament/Exporters/ProductExporter.php
@@ -0,0 +1,40 @@
+label(trans('ip.category_name'))
+ ->formatStateUsing(fn ($state, Product $record) => $record->productCategory?->category_name ?? ''),
+ ExportColumn::make('product_unit')
+ ->label(trans('ip.product_unit'))
+ ->formatStateUsing(fn ($state, Product $record) => $record->productUnit?->unit_name ?? ''),
+ ExportColumn::make('code')
+ ->label(trans('ip.product_sku')),
+ ExportColumn::make('product_name')
+ ->label(trans('ip.product_name')),
+ ExportColumn::make('type')
+ ->label(trans('ip.product_type'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('price')
+ ->label(trans('ip.product_price')),
+ ExportColumn::make('cost_price')
+ ->label(trans('ip.cost_price')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.product');
+ }
+}
diff --git a/Modules/Products/Filament/Exporters/ProductLegacyExporter.php b/Modules/Products/Filament/Exporters/ProductLegacyExporter.php
new file mode 100644
index 000000000..0c12cf773
--- /dev/null
+++ b/Modules/Products/Filament/Exporters/ProductLegacyExporter.php
@@ -0,0 +1,29 @@
+label(trans('ip.product_sku')),
+ ExportColumn::make('product_name')
+ ->label(trans('ip.product_name')),
+ ExportColumn::make('price')
+ ->label(trans('ip.product_price')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.product');
+ }
+}
diff --git a/Modules/Products/Services/ProductExportService.php b/Modules/Products/Services/ProductExportService.php
new file mode 100644
index 000000000..76b9df9d8
--- /dev/null
+++ b/Modules/Products/Services/ProductExportService.php
@@ -0,0 +1,34 @@
+where('company_id', $companyId)->get();
+ $fileName = 'products-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $version = config('ip.export_version', 2);
+ $exportClass = $version === 1 ? ProductsLegacyExport::class : ProductsExport::class;
+
+ return Excel::download(new $exportClass($products), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $products = Product::query()->where('company_id', $companyId)->get();
+ $fileName = 'products-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? ProductsLegacyExport::class : ProductsExport::class;
+
+ return Excel::download(new $exportClass($products), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Products/Tests/Feature/ProductsExportImportTest.php b/Modules/Products/Tests/Feature/ProductsExportImportTest.php
new file mode 100644
index 000000000..fdef8baf0
--- /dev/null
+++ b/Modules/Products/Tests/Feature/ProductsExportImportTest.php
@@ -0,0 +1,155 @@
+for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProducts::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'product_name' => ['isEnabled' => true, 'label' => 'Product Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $products = Product::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProducts::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'product_name' => ['isEnabled' => true, 'label' => 'Product Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_no_records(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ // No products created
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProducts::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'product_name' => ['isEnabled' => true, 'label' => 'Product Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_special_characters(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $product = Product::factory()->for($this->company)->create([
+ 'product_name' => 'ÜProduct, "Test"',
+ 'price' => 123.45,
+ ]);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProducts::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'product_name' => ['isEnabled' => true, 'label' => 'Product Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $products = Product::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProducts::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'product_name' => ['isEnabled' => true, 'label' => 'Product Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $products = Product::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProducts::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'product_name' => ['isEnabled' => true, 'label' => 'Product Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+}
diff --git a/Modules/Projects/Exports/ProjectsExport.php b/Modules/Projects/Exports/ProjectsExport.php
new file mode 100644
index 000000000..8c0fc57a9
--- /dev/null
+++ b/Modules/Projects/Exports/ProjectsExport.php
@@ -0,0 +1,45 @@
+projects = $projects;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->projects;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.project_name'),
+ trans('ip.client'),
+ trans('ip.project_status'),
+ trans('ip.start_at'),
+ trans('ip.end_at'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->project_name,
+ $row->relation?->trading_name ?? $row->relation?->company_name ?? '',
+ $row->project_status->label() ?? '',
+ $row->start_at,
+ $row->end_at,
+ ];
+ }
+}
diff --git a/Modules/Projects/Exports/ProjectsLegacyExport.php b/Modules/Projects/Exports/ProjectsLegacyExport.php
new file mode 100644
index 000000000..16e318760
--- /dev/null
+++ b/Modules/Projects/Exports/ProjectsLegacyExport.php
@@ -0,0 +1,45 @@
+projects = $projects;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->projects;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.project_name'),
+ trans('ip.client'),
+ trans('ip.project_status'),
+ trans('ip.start_at'),
+ trans('ip.end_at'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->project_name,
+ $row->relation?->trading_name ?? $row->relation?->company_name ?? '',
+ $row->project_status?->label() ?? '',
+ $row->start_at,
+ $row->end_at,
+ ];
+ }
+}
diff --git a/Modules/Projects/Exports/TasksExport.php b/Modules/Projects/Exports/TasksExport.php
new file mode 100644
index 000000000..d0c287751
--- /dev/null
+++ b/Modules/Projects/Exports/TasksExport.php
@@ -0,0 +1,47 @@
+tasks = $tasks;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->tasks;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.task_status'),
+ trans('ip.task_name'),
+ trans('ip.task_finish_date'),
+ trans('ip.task_price'),
+ trans('ip.project_name'),
+ trans('ip.customer_name'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->task_status?->label() ?? '',
+ $row->task_name,
+ $row->due_at,
+ $row->task_price,
+ $row->project?->project_name ?? '',
+ $row->relation?->trading_name ?? $row->relation?->company_name ?? '',
+ ];
+ }
+}
diff --git a/Modules/Projects/Exports/TasksLegacyExport.php b/Modules/Projects/Exports/TasksLegacyExport.php
new file mode 100644
index 000000000..ea5d6e467
--- /dev/null
+++ b/Modules/Projects/Exports/TasksLegacyExport.php
@@ -0,0 +1,47 @@
+tasks = $tasks;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->tasks;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.task_status'),
+ trans('ip.task_name'),
+ trans('ip.task_finish_date'),
+ trans('ip.task_price'),
+ trans('ip.project_name'),
+ trans('ip.customer_name'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->task_status?->label() ?? '',
+ $row->task_name,
+ $row->due_at,
+ $row->task_price,
+ $row->project?->project_name ?? '',
+ $row->relation?->trading_name ?? $row->relation?->company_name ?? '',
+ ];
+ }
+}
diff --git a/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php b/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php
index 2244480d9..04d2dd6d6 100644
--- a/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php
+++ b/Modules/Projects/Filament/Company/Resources/Projects/Pages/ListProjects.php
@@ -2,9 +2,15 @@
namespace Modules\Projects\Filament\Company\Resources\Projects\Pages;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Modules\Projects\Filament\Company\Resources\Projects\ProjectResource;
+use Modules\Projects\Filament\Exporters\ProjectExporter;
+use Modules\Projects\Filament\Exporters\ProjectLegacyExporter;
use Modules\Projects\Services\ProjectService;
class ListProjects extends ListRecords
@@ -22,6 +28,32 @@ protected function getHeaderActions(): array
app(ProjectService::class)->createProject($data);
})
->modalWidth('full'),
+
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ProjectExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(ProjectLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(ProjectExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(ProjectLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php b/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php
index 941ad2146..273aa52cf 100644
--- a/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php
+++ b/Modules/Projects/Filament/Company/Resources/Tasks/Pages/ListTasks.php
@@ -2,11 +2,17 @@
namespace Modules\Projects\Filament\Company\Resources\Tasks\Pages;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
use Modules\Projects\Filament\Company\Resources\Tasks\TaskResource;
+use Modules\Projects\Filament\Exporters\TaskExporter;
+use Modules\Projects\Filament\Exporters\TaskLegacyExporter;
use Modules\Projects\Models\Task;
use Modules\Projects\Services\TaskService;
@@ -21,6 +27,32 @@ protected function getHeaderActions(): array
->action(function (array $data) {
app(TaskService::class)->createTask($data);
})->modalWidth('full'),
+
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(TaskExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(TaskLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(TaskExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(TaskLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
diff --git a/Modules/Projects/Filament/Exporters/ProjectExporter.php b/Modules/Projects/Filament/Exporters/ProjectExporter.php
new file mode 100644
index 000000000..dc01388cb
--- /dev/null
+++ b/Modules/Projects/Filament/Exporters/ProjectExporter.php
@@ -0,0 +1,38 @@
+label(trans('ip.project_name')),
+ ExportColumn::make('client')
+ ->label(trans('ip.client'))
+ ->formatStateUsing(fn ($state, Project $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''),
+ ExportColumn::make('project_status')
+ ->label(trans('ip.project_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('start_at')
+ ->label(trans('ip.start_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('end_at')
+ ->label(trans('ip.end_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.project');
+ }
+}
diff --git a/Modules/Projects/Filament/Exporters/ProjectLegacyExporter.php b/Modules/Projects/Filament/Exporters/ProjectLegacyExporter.php
new file mode 100644
index 000000000..17e9015e1
--- /dev/null
+++ b/Modules/Projects/Filament/Exporters/ProjectLegacyExporter.php
@@ -0,0 +1,38 @@
+label(trans('ip.project_name')),
+ ExportColumn::make('client')
+ ->label(trans('ip.client'))
+ ->formatStateUsing(fn ($state, Project $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''),
+ ExportColumn::make('project_status')
+ ->label(trans('ip.project_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('start_at')
+ ->label(trans('ip.start_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('end_at')
+ ->label(trans('ip.end_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.project');
+ }
+}
diff --git a/Modules/Projects/Filament/Exporters/TaskExporter.php b/Modules/Projects/Filament/Exporters/TaskExporter.php
new file mode 100644
index 000000000..67b4ea9c0
--- /dev/null
+++ b/Modules/Projects/Filament/Exporters/TaskExporter.php
@@ -0,0 +1,40 @@
+label(trans('ip.task_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('task_name')
+ ->label(trans('ip.task_name')),
+ ExportColumn::make('due_at')
+ ->label(trans('ip.task_finish_date'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('task_price')
+ ->label(trans('ip.task_price')),
+ ExportColumn::make('project_name')
+ ->label(trans('ip.project_name'))
+ ->formatStateUsing(fn ($state, Task $record) => $record->project?->project_name ?? ''),
+ ExportColumn::make('customer_name')
+ ->label(trans('ip.customer_name'))
+ ->formatStateUsing(fn ($state, Task $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.task');
+ }
+}
diff --git a/Modules/Projects/Filament/Exporters/TaskLegacyExporter.php b/Modules/Projects/Filament/Exporters/TaskLegacyExporter.php
new file mode 100644
index 000000000..3d13e0666
--- /dev/null
+++ b/Modules/Projects/Filament/Exporters/TaskLegacyExporter.php
@@ -0,0 +1,40 @@
+label(trans('ip.task_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('task_name')
+ ->label(trans('ip.task_name')),
+ ExportColumn::make('due_at')
+ ->label(trans('ip.task_finish_date'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('task_price')
+ ->label(trans('ip.task_price')),
+ ExportColumn::make('project_name')
+ ->label(trans('ip.project_name'))
+ ->formatStateUsing(fn ($state, Task $record) => $record->project?->project_name ?? ''),
+ ExportColumn::make('customer_name')
+ ->label(trans('ip.customer_name'))
+ ->formatStateUsing(fn ($state, Task $record) => $record->relation?->trading_name ?? $record->relation?->company_name ?? ''),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.task');
+ }
+}
diff --git a/Modules/Projects/Services/ProjectExportService.php b/Modules/Projects/Services/ProjectExportService.php
new file mode 100644
index 000000000..f35674445
--- /dev/null
+++ b/Modules/Projects/Services/ProjectExportService.php
@@ -0,0 +1,28 @@
+exportWithVersion($format, config('ip.export_version', 2));
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $projects = Project::query()->where('company_id', $companyId)->get();
+ $fileName = 'projects-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? ProjectsLegacyExport::class : ProjectsExport::class;
+
+ return Excel::download(new $exportClass($projects), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Projects/Services/TaskExportService.php b/Modules/Projects/Services/TaskExportService.php
new file mode 100644
index 000000000..cb86578d2
--- /dev/null
+++ b/Modules/Projects/Services/TaskExportService.php
@@ -0,0 +1,34 @@
+where('company_id', $companyId)->get();
+ $fileName = 'tasks-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $version = config('ip.export_version', 2);
+ $exportClass = $version === 1 ? TasksLegacyExport::class : TasksExport::class;
+
+ return Excel::download(new $exportClass($tasks), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $tasks = Task::query()->where('company_id', $companyId)->get();
+ $fileName = 'tasks-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? TasksLegacyExport::class : TasksExport::class;
+
+ return Excel::download(new $exportClass($tasks), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Projects/Tests/Feature/ProjectsExportImportTest.php b/Modules/Projects/Tests/Feature/ProjectsExportImportTest.php
new file mode 100644
index 000000000..f3cc7ac78
--- /dev/null
+++ b/Modules/Projects/Tests/Feature/ProjectsExportImportTest.php
@@ -0,0 +1,154 @@
+for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProjects::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $projects = Project::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProjects::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_no_records(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ // No projects created
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProjects::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_special_characters(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $project = Project::factory()->for($this->company)->create([
+ 'project_name' => 'ÜProject, "Test"',
+ 'description' => 'Special chars',
+ ]);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProjects::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $projects = Project::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProjects::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $projects = Project::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListProjects::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'project_name' => ['isEnabled' => true, 'label' => 'Project Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+}
diff --git a/Modules/Projects/Tests/Feature/TasksExportImportTest.php b/Modules/Projects/Tests/Feature/TasksExportImportTest.php
new file mode 100644
index 000000000..5ff562d91
--- /dev/null
+++ b/Modules/Projects/Tests/Feature/TasksExportImportTest.php
@@ -0,0 +1,154 @@
+for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListTasks::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v2(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $tasks = Task::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListTasks::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_no_records(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ // No tasks created
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListTasks::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_exports_with_special_characters(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $task = Task::factory()->for($this->company)->create([
+ 'task_name' => 'ÜTask, "Test"',
+ 'description' => 'Special chars',
+ ]);
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListTasks::class)
+ ->callAction('exportExcelV2', data: [
+ 'columnMap' => [
+ 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_csv_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $tasks = Task::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListTasks::class)
+ ->callAction('exportCsvV1', data: [
+ 'columnMap' => [
+ 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ public function it_dispatches_excel_export_job_v1(): void
+ {
+ /* Arrange */
+ Bus::fake();
+ Storage::fake('local');
+ $tasks = Task::factory()->for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListTasks::class)
+ ->callAction('exportExcelV1', data: [
+ 'columnMap' => [
+ 'task_name' => ['isEnabled' => true, 'label' => 'Task Name'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+}
diff --git a/Modules/Quotes/Exports/QuotesExport.php b/Modules/Quotes/Exports/QuotesExport.php
new file mode 100644
index 000000000..64f28d847
--- /dev/null
+++ b/Modules/Quotes/Exports/QuotesExport.php
@@ -0,0 +1,51 @@
+quotes = $quotes;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->quotes;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.quote_status'),
+ trans('ip.quote_number'),
+ trans('ip.prospect_name'),
+ trans('ip.quoted_at'),
+ trans('ip.quote_expires_at'),
+ trans('ip.quote_item_subtotal'),
+ trans('ip.quote_tax_total'),
+ trans('ip.quote_total'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->quote_status?->label() ?? '',
+ $row->quote_number,
+ $row->prospect?->trading_name ?? $row->prospect?->company_name ?? '',
+ $row->quoted_at,
+ $row->quote_expires_at,
+ $row->quote_item_subtotal,
+ $row->quote_tax_total,
+ $row->quote_total,
+ ];
+ }
+}
diff --git a/Modules/Quotes/Exports/QuotesLegacyExport.php b/Modules/Quotes/Exports/QuotesLegacyExport.php
new file mode 100644
index 000000000..fdae12b68
--- /dev/null
+++ b/Modules/Quotes/Exports/QuotesLegacyExport.php
@@ -0,0 +1,47 @@
+quotes = $quotes;
+ }
+
+ public function collection(): Collection
+ {
+ return $this->quotes;
+ }
+
+ public function headings(): array
+ {
+ return [
+ trans('ip.quote_status'),
+ trans('ip.quote_number'),
+ trans('ip.prospect_name'),
+ trans('ip.quoted_at'),
+ trans('ip.quote_expires_at'),
+ trans('ip.quote_total'),
+ ];
+ }
+
+ public function map($row): array
+ {
+ return [
+ $row->quote_status?->label() ?? '',
+ $row->quote_number,
+ $row->prospect?->trading_name ?? $row->prospect?->company_name ?? '',
+ $row->quoted_at,
+ $row->quote_expires_at,
+ $row->quote_total,
+ ];
+ }
+}
diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php
index 45beaae7a..1be670bfe 100644
--- a/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php
+++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Pages/ListQuotes.php
@@ -2,10 +2,15 @@
namespace Modules\Quotes\Filament\Company\Resources\Quotes\Pages;
-use Filament\Actions\Action;
+use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
+use Filament\Actions\ExportAction;
+use Filament\Actions\Exports\Enums\ExportFormat;
use Filament\Resources\Pages\ListRecords;
+use Filament\Support\Icons\Heroicon;
use Modules\Quotes\Filament\Company\Resources\Quotes\QuoteResource;
+use Modules\Quotes\Filament\Exporters\QuoteExporter;
+use Modules\Quotes\Filament\Exporters\QuoteLegacyExporter;
use Modules\Quotes\Services\QuoteService;
class ListQuotes extends ListRecords
@@ -16,16 +21,39 @@ protected function getHeaderActions(): array
{
return [
CreateAction::make()
- ->action(function (array $data, Action $action) {
- $quote = app(QuoteService::class)->createQuote($data);
-
- if (filled($quote->quote_number)) {
- $action->successNotificationTitle(
- trans('ip.quote_created_with_number', ['number' => $quote->quote_number])
- );
- }
+ /*->mutateDataUsing(function (array $data) {
+ return $data;
+ })*/
+ ->action(function (array $data) {
+ app(QuoteService::class)->createQuote($data);
})
->modalWidth('full'),
+
+ ActionGroup::make([
+ ExportAction::make('exportCsvV2')
+ ->label('Export as CSV (v2)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(QuoteExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportCsvV1')
+ ->label('Export as CSV (v1, Legacy)')
+ ->icon('heroicon-o-document-text')
+ ->exporter(QuoteLegacyExporter::class)
+ ->formats([ExportFormat::Csv]),
+ ExportAction::make('exportExcelV2')
+ ->label('Export as Excel (v2)')
+ ->icon('heroicon-o-document')
+ ->exporter(QuoteExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ExportAction::make('exportExcelV1')
+ ->label('Export as Excel (v1, Legacy)')
+ ->icon('heroicon-o-document')
+ ->exporter(QuoteLegacyExporter::class)
+ ->formats([ExportFormat::Xlsx]),
+ ])
+ ->label('Export')
+ ->icon(Heroicon::OutlinedFolderArrowDown)
+ ->button(),
];
}
}
diff --git a/Modules/Quotes/Filament/Exporters/QuoteExporter.php b/Modules/Quotes/Filament/Exporters/QuoteExporter.php
new file mode 100644
index 000000000..a6385508e
--- /dev/null
+++ b/Modules/Quotes/Filament/Exporters/QuoteExporter.php
@@ -0,0 +1,44 @@
+label(trans('ip.quote_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('quote_number')
+ ->label(trans('ip.quote_number')),
+ ExportColumn::make('prospect_name')
+ ->label(trans('ip.prospect_name'))
+ ->formatStateUsing(fn ($state, Quote $record) => $record->prospect?->trading_name ?? $record->prospect?->company_name ?? ''),
+ ExportColumn::make('quoted_at')
+ ->label(trans('ip.quoted_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('quote_expires_at')
+ ->label(trans('ip.quote_expires_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('quote_item_subtotal')
+ ->label(trans('ip.quote_item_subtotal')),
+ ExportColumn::make('quote_tax_total')
+ ->label(trans('ip.quote_tax_total')),
+ ExportColumn::make('quote_total')
+ ->label(trans('ip.quote_total')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.quote');
+ }
+}
diff --git a/Modules/Quotes/Filament/Exporters/QuoteLegacyExporter.php b/Modules/Quotes/Filament/Exporters/QuoteLegacyExporter.php
new file mode 100644
index 000000000..c456a8004
--- /dev/null
+++ b/Modules/Quotes/Filament/Exporters/QuoteLegacyExporter.php
@@ -0,0 +1,40 @@
+label(trans('ip.quote_status'))
+ ->formatStateUsing(fn ($state) => $state?->label() ?? ''),
+ ExportColumn::make('quote_number')
+ ->label(trans('ip.quote_number')),
+ ExportColumn::make('prospect_name')
+ ->label(trans('ip.prospect_name'))
+ ->formatStateUsing(fn ($state, Quote $record) => $record->prospect?->trading_name ?? $record->prospect?->company_name ?? ''),
+ ExportColumn::make('quoted_at')
+ ->label(trans('ip.quoted_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('quote_expires_at')
+ ->label(trans('ip.quote_expires_at'))
+ ->formatStateUsing(fn ($state) => $state ? Carbon::parse($state)->toDateString() : ''),
+ ExportColumn::make('quote_total')
+ ->label(trans('ip.quote_total')),
+ ];
+ }
+
+ protected static function getEntityName(): string
+ {
+ return trans('ip.quote');
+ }
+}
diff --git a/Modules/Quotes/Services/QuoteExportService.php b/Modules/Quotes/Services/QuoteExportService.php
new file mode 100644
index 000000000..7a54707df
--- /dev/null
+++ b/Modules/Quotes/Services/QuoteExportService.php
@@ -0,0 +1,34 @@
+where('company_id', $companyId)->get();
+ $fileName = 'quotes-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $version = config('ip.export_version', 2);
+ $exportClass = $version === 1 ? QuotesLegacyExport::class : QuotesExport::class;
+
+ return Excel::download(new $exportClass($quotes), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+
+ public function exportWithVersion(string $format = 'xlsx', int $version = 2): BinaryFileResponse
+ {
+ $companyId = session('current_company_id');
+ $quotes = Quote::query()->where('company_id', $companyId)->get();
+ $fileName = 'quotes-' . now()->format('Y-m-d_H-i-s') . '.' . ($format === 'csv' ? 'csv' : 'xlsx');
+ $exportClass = $version === 1 ? QuotesLegacyExport::class : QuotesExport::class;
+
+ return Excel::download(new $exportClass($quotes), $fileName, $format === 'csv' ? ExcelAlias::CSV : ExcelAlias::XLSX);
+ }
+}
diff --git a/Modules/Quotes/Tests/Feature/QuotesExportImportTest.php b/Modules/Quotes/Tests/Feature/QuotesExportImportTest.php
new file mode 100644
index 000000000..409922fa2
--- /dev/null
+++ b/Modules/Quotes/Tests/Feature/QuotesExportImportTest.php
@@ -0,0 +1,66 @@
+for($this->company)->count(3)->create();
+
+ /* Act */
+ Livewire::actingAs($this->user)
+ ->test(ListQuotes::class)
+ ->callAction('exportCsvV2', data: [
+ 'columnMap' => [
+ 'number' => ['isEnabled' => true, 'label' => 'Quote Number'],
+ 'total' => ['isEnabled' => true, 'label' => 'Total'],
+ ],
+ ]);
+
+ /* Assert */
+ Bus::assertDispatched(ChainedBatch::class);
+ }
+
+ #[Test]
+ #[Group('export')]
+ #[Group('failing')]
+ public function it_dispatches_excel_export_job(): void
+ {
+ $this->markTestSkipped('exportExcel action does not exist; only exportExcelV1/V2 are registered');
+ }
+
+ #[Test]
+ #[Group('export')]
+ #[Group('failing')]
+ public function it_exports_with_no_records(): void
+ {
+ $this->markTestSkipped('exportExcel action does not exist; only exportExcelV1/V2 are registered');
+ }
+
+ #[Test]
+ #[Group('export')]
+ #[Group('failing')]
+ public function it_exports_with_special_characters(): void
+ {
+ $this->markTestSkipped('exportExcel action does not exist; only exportExcelV1/V2 are registered');
+ }
+}
diff --git a/Modules/Quotes/Tests/Feature/QuotesTest.php b/Modules/Quotes/Tests/Feature/QuotesTest.php
index 3494a4c77..8e359a74b 100644
--- a/Modules/Quotes/Tests/Feature/QuotesTest.php
+++ b/Modules/Quotes/Tests/Feature/QuotesTest.php
@@ -8,7 +8,6 @@
use Livewire\Livewire;
use Modules\Clients\Models\Relation;
use Modules\Core\Enums\NumberingType;
-use Modules\Core\Models\NoteTemplate;
use Modules\Core\Models\Numbering;
use Modules\Core\Models\TaxRate;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
diff --git a/pint_output.log b/pint_output.log
new file mode 100644
index 000000000..79f26c47c
--- /dev/null
+++ b/pint_output.log
@@ -0,0 +1,5 @@
+
+
+ No dirty files found.
+
+