From 7ace7661e1a13f2bb7e8fa9ce4a9779415833a12 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 9 Jul 2026 22:40:59 +0200 Subject: [PATCH 01/14] feat: let integrations register export formats via export providers Exports now resolve through an ExportProviderRegistry instead of a hardcoded switch, so integration packages can expose their own export formats next to the built-in ones. BudgetUsageService is no longer serialized with the queued export job. --- app/DataTransferObjects/ExportFormat.php | 15 ++++ app/DataTransferObjects/ExportPeriod.php | 39 ++++++++ app/Enums/ExportDateRequirement.php | 10 +++ app/Exports/BudgetsExport.php | 3 +- app/Exports/CoreExportProvider.php | 41 +++++++++ app/Exports/EntriesExport.php | 3 +- app/Exports/MonthlyBudgetsExport.php | 3 +- app/Exports/UsersMonthlySummaryExport.php | 3 +- .../Controllers/ExportEmailController.php | 5 +- app/Http/Requests/ExportRequest.php | 16 +++- .../Contracts/ExportInterface.php | 8 ++ .../Contracts/ExportProviderInterface.php | 20 +++++ app/Integrations/ExportProviderRegistry.php | 53 +++++++++++ app/Integrations/ExportService.php | 72 +++++++++++++++ app/Jobs/ExportBudgetsJob.php | 89 +++++-------------- app/Providers/AppServiceProvider.php | 7 ++ 16 files changed, 307 insertions(+), 80 deletions(-) create mode 100644 app/DataTransferObjects/ExportFormat.php create mode 100644 app/DataTransferObjects/ExportPeriod.php create mode 100644 app/Enums/ExportDateRequirement.php create mode 100644 app/Exports/CoreExportProvider.php create mode 100644 app/Integrations/Contracts/ExportInterface.php create mode 100644 app/Integrations/Contracts/ExportProviderInterface.php create mode 100644 app/Integrations/ExportProviderRegistry.php create mode 100644 app/Integrations/ExportService.php diff --git a/app/DataTransferObjects/ExportFormat.php b/app/DataTransferObjects/ExportFormat.php new file mode 100644 index 0000000..b897ae3 --- /dev/null +++ b/app/DataTransferObjects/ExportFormat.php @@ -0,0 +1,15 @@ +year, $this->month ?? 1, 1) === null) { + throw new InvalidArgumentException("Invalid date for year {$this->year} and month {$this->month}"); + } + } + + public function start(): CarbonImmutable + { + $start = CarbonImmutable::create($this->year, $this->month ?? 1, 1); + + assert($start !== null); + + return $start; + } + + public function end(): CarbonImmutable + { + return $this->month === null + ? $this->start()->endOfYear() + : $this->start()->endOfMonth(); + } + + public function requireMonth(): int + { + return $this->month ?? throw new InvalidArgumentException('This export requires a month.'); + } +} diff --git a/app/Enums/ExportDateRequirement.php b/app/Enums/ExportDateRequirement.php new file mode 100644 index 0000000..4af1eb7 --- /dev/null +++ b/app/Enums/ExportDateRequirement.php @@ -0,0 +1,10 @@ + new MonthlyBudgetsExport($period->year, $period->requireMonth(), app(BudgetUsageService::class)), + 'budgets-excel' => new BudgetsExport, + 'entries-excel' => new EntriesExport($period->start(), $period->end()), + 'users-monthly-summary-excel' => new UsersMonthlySummaryExport($period->start(), $period->end()), + default => throw new InvalidArgumentException("Unknown export format: {$key}"), + }; + } + + public static function fromConfig(array $config): static + { + return new self; + } +} diff --git a/app/Exports/EntriesExport.php b/app/Exports/EntriesExport.php index 0914214..4627c19 100644 --- a/app/Exports/EntriesExport.php +++ b/app/Exports/EntriesExport.php @@ -3,6 +3,7 @@ namespace App\Exports; use App\DataTransferObjects\EntryExportRow; +use App\Integrations\Contracts\ExportInterface; use App\Models\Entry; use App\Models\User; use Brick\Math\BigDecimal; @@ -12,7 +13,7 @@ use OpenSpout\Common\Entity\Row; use OpenSpout\Writer\XLSX\Writer; -class EntriesExport +class EntriesExport implements ExportInterface { private CarbonInterface $start; diff --git a/app/Exports/MonthlyBudgetsExport.php b/app/Exports/MonthlyBudgetsExport.php index 2b561db..96bb042 100644 --- a/app/Exports/MonthlyBudgetsExport.php +++ b/app/Exports/MonthlyBudgetsExport.php @@ -3,6 +3,7 @@ namespace App\Exports; use App\DataTransferObjects\BudgetMutation; +use App\Integrations\Contracts\ExportInterface; use App\Models\Customer; use App\Models\User; use App\Services\BudgetUsageService; @@ -11,7 +12,7 @@ use OpenSpout\Common\Entity\Row; use OpenSpout\Writer\XLSX\Writer; -class MonthlyBudgetsExport +class MonthlyBudgetsExport implements ExportInterface { private Carbon $month; diff --git a/app/Exports/UsersMonthlySummaryExport.php b/app/Exports/UsersMonthlySummaryExport.php index 6e36061..561a8a9 100644 --- a/app/Exports/UsersMonthlySummaryExport.php +++ b/app/Exports/UsersMonthlySummaryExport.php @@ -2,6 +2,7 @@ namespace App\Exports; +use App\Integrations\Contracts\ExportInterface; use App\Models\Entry; use App\Models\User; use App\Queries\HoursPerUserPerMonth; @@ -11,7 +12,7 @@ use OpenSpout\Common\Entity\Row; use OpenSpout\Writer\XLSX\Writer; -class UsersMonthlySummaryExport +class UsersMonthlySummaryExport implements ExportInterface { public function __construct( private CarbonInterface $start, diff --git a/app/Http/Controllers/ExportEmailController.php b/app/Http/Controllers/ExportEmailController.php index ce4ee54..3a604a5 100644 --- a/app/Http/Controllers/ExportEmailController.php +++ b/app/Http/Controllers/ExportEmailController.php @@ -5,7 +5,6 @@ use App\Http\Requests\ExportRequest; use App\Jobs\ExportBudgetsJob; use App\Models\User; -use App\Services\BudgetUsageService; use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\JsonResponse; @@ -19,7 +18,7 @@ class ExportEmailController extends Controller * @return JsonResponse */ #[ExcludeRouteFromDocs] - public function __invoke(ExportRequest $request, BudgetUsageService $usageService, #[CurrentUser] User $user) + public function __invoke(ExportRequest $request, #[CurrentUser] User $user) { $validated = $request->validated(); @@ -27,7 +26,7 @@ public function __invoke(ExportRequest $request, BudgetUsageService $usageServic $year = $validated['year']; $month = $validated['month'] ?? null; - ExportBudgetsJob::dispatch($user, $exportType, $year, $month, $usageService); + ExportBudgetsJob::dispatch($user, $exportType, $year, $month); return response()->json(['message' => __('Export gestart. Je ontvangt een e-mail zodra het bestand klaar is.')], Response::HTTP_ACCEPTED); } diff --git a/app/Http/Requests/ExportRequest.php b/app/Http/Requests/ExportRequest.php index f140dd6..26df62b 100644 --- a/app/Http/Requests/ExportRequest.php +++ b/app/Http/Requests/ExportRequest.php @@ -2,8 +2,11 @@ namespace App\Http\Requests; +use App\Enums\ExportDateRequirement; +use App\Integrations\ExportService; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; class ExportRequest extends FormRequest { @@ -20,12 +23,17 @@ public function authorize(): bool * * @return array|string> */ - public function rules(): array + public function rules(ExportService $exportService): array { + $format = $exportService->findFormat($this->string('exportType')->toString()); + return [ - 'exportType' => 'required|string', - 'year' => 'required|integer|min:2000|max:'.date('Y'), - 'month' => 'required_unless:exportType,entries-excel|nullable|integer|min:1|max:12', + 'exportType' => ['required', 'string', Rule::in($exportService->formatKeys())], + 'year' => ['required', 'integer', 'min:2000', 'max:'.date('Y')], + 'month' => [ + Rule::requiredIf($format?->dateRequirement === ExportDateRequirement::Monthly), + 'nullable', 'integer', 'min:1', 'max:12', + ], ]; } } diff --git a/app/Integrations/Contracts/ExportInterface.php b/app/Integrations/Contracts/ExportInterface.php new file mode 100644 index 0000000..60207fd --- /dev/null +++ b/app/Integrations/Contracts/ExportInterface.php @@ -0,0 +1,8 @@ + + */ + public function exportFormats(): Collection; + + public function createExport(string $key, ExportPeriod $period): ExportInterface; + + /** @param array $config */ + public static function fromConfig(array $config): static; +} diff --git a/app/Integrations/ExportProviderRegistry.php b/app/Integrations/ExportProviderRegistry.php new file mode 100644 index 0000000..cd86fb4 --- /dev/null +++ b/app/Integrations/ExportProviderRegistry.php @@ -0,0 +1,53 @@ +>> */ + private array $classes = []; + + /** @var list> */ + private array $globalClasses = []; + + /** @param class-string $providerClass */ + public function register(string $type, string $providerClass): void + { + $this->classes[$type][] = $providerClass; + } + + /** @param class-string $providerClass */ + public function registerGlobal(string $providerClass): void + { + $this->globalClasses[] = $providerClass; + } + + /** + * @param array $config + * @return list + */ + public function makeProviders(string $type, array $config): array + { + return array_map( + fn (string $class): ExportProviderInterface => $class::fromConfig($config), + $this->classes[$type] ?? [], + ); + } + + /** @return list */ + public function makeGlobalProviders(): array + { + return array_map( + fn (string $class): ExportProviderInterface => $class::fromConfig([]), + $this->globalClasses, + ); + } + + /** @return list */ + public function registeredTypes(): array + { + return array_keys($this->classes); + } +} diff --git a/app/Integrations/ExportService.php b/app/Integrations/ExportService.php new file mode 100644 index 0000000..2c538fe --- /dev/null +++ b/app/Integrations/ExportService.php @@ -0,0 +1,72 @@ + */ + public function formats(): Collection + { + $formats = $this->resolveProviders() + ->flatMap(fn (ExportProviderInterface $provider) => $provider->exportFormats()) + ->values(); + + $duplicateKeys = $formats->countBy(fn (ExportFormat $format) => $format->key)->filter(fn (int $count) => $count > 1); + + if ($duplicateKeys->isNotEmpty()) { + throw new RuntimeException('Duplicate export format keys registered: '.$duplicateKeys->keys()->implode(', ')); + } + + return $formats; + } + + public function findFormat(string $key): ?ExportFormat + { + return $this->formats()->first(fn (ExportFormat $format) => $format->key === $key); + } + + /** @return list */ + public function formatKeys(): array + { + return array_values($this->formats()->map(fn (ExportFormat $format) => $format->key)->all()); + } + + public function createExport(string $key, ExportPeriod $period): ExportInterface + { + $provider = $this->resolveProviders()->first( + fn (ExportProviderInterface $provider) => $provider->exportFormats()->contains( + fn (ExportFormat $format) => $format->key === $key, + ), + ); + + if ($provider === null) { + throw new InvalidArgumentException("Unknown export format: {$key}"); + } + + return $provider->createExport($key, $period); + } + + /** @return Collection */ + private function resolveProviders(): Collection + { + return collect($this->registry->makeGlobalProviders()) + ->merge( + Integration::whereIn('type', $this->registry->registeredTypes())->get() + ->flatMap(fn (Integration $integration) => $this->registry->makeProviders( + $integration->type, + [...($integration->config ?? []), 'integration_id' => $integration->id], + )), + ); + } +} diff --git a/app/Jobs/ExportBudgetsJob.php b/app/Jobs/ExportBudgetsJob.php index 2fcb8c4..28b87cf 100644 --- a/app/Jobs/ExportBudgetsJob.php +++ b/app/Jobs/ExportBudgetsJob.php @@ -2,101 +2,52 @@ namespace App\Jobs; -use App\Exports\BudgetsExport; -use App\Exports\EntriesExport; -use App\Exports\MonthlyBudgetsExport; -use App\Exports\UsersMonthlySummaryExport; +use App\DataTransferObjects\ExportPeriod; +use App\Integrations\ExportService; use App\Mail\ExportEmail; use App\Models\User; -use App\Services\BudgetUsageService; -use Carbon\CarbonImmutable; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use InvalidArgumentException; -use Webmozart\Assert\Assert; class ExportBudgetsJob implements ShouldQueue { use Dispatchable, Queueable; - protected User $user; + public function __construct( + protected User $user, + protected string $exportType, + protected int $year, + protected ?int $month, + ) {} - protected string $exportType; - - protected int $year; - - protected ?int $month; + public function handle(ExportService $exportService): void + { + $format = $exportService->findFormat($this->exportType) + ?? throw new InvalidArgumentException("Invalid export type: {$this->exportType}"); - protected BudgetUsageService $usageService; + $fileName = "export_{$this->exportType}_{$this->year}_".($this->month ?? 'all').".{$format->extension}"; - public function __construct(User $user, string $exportType, int $year, ?int $month, BudgetUsageService $usageService) - { - $this->user = $user; - $this->exportType = $exportType; - $this->year = $year; - $this->month = $month; - $this->usageService = $usageService; - } + $exportService + ->createExport($this->exportType, new ExportPeriod($this->year, $this->month)) + ->export(Storage::disk('temp')->path($fileName)); - public function handle(): void - { - $fileName = $this->generateFileName(); - $this->exportData($fileName); + $this->uploadToS3($fileName); $this->sendExportEmail($fileName); } - private function generateFileName(): string + private function uploadToS3(string $fileName): void { - return "export_{$this->exportType}_{$this->year}_".($this->month ?? 'all').'.xlsx'; - } - - private function exportData(string $fileName): void - { - $start = CarbonImmutable::create($this->year, $this->month ?? 1, 1); - if ($start === null) { - throw new InvalidArgumentException("Invalid date for year {$this->year} and month {$this->month}"); - } - - if ($this->month === null) { - $end = $start->endOfYear(); - } else { - $end = $start->endOfMonth(); - } - - $localFilePath = Storage::disk('temp')->path($fileName); - - switch ($this->exportType) { - case 'budgets-monthly-excel': - Assert::notNull($this->month); - (new MonthlyBudgetsExport($this->year, $this->month, $this->usageService))->export($localFilePath); - break; - - case 'budgets-excel': - (new BudgetsExport)->export($localFilePath); - break; - - case 'entries-excel': - (new EntriesExport($start, $end))->export($localFilePath); - break; - - case 'users-monthly-summary-excel': - (new UsersMonthlySummaryExport($start, $end))->export($localFilePath); - break; - default: - throw new InvalidArgumentException("Invalid export type: {$this->exportType}"); - } - $content = Storage::disk('temp')->get($fileName); - if ($content !== null) { - Storage::disk('s3')->put($fileName, $content); - } else { + if ($content === null) { throw new InvalidArgumentException("File {$fileName} does not exist"); } + Storage::disk('s3')->put($fileName, $content); } private function sendExportEmail(string $fileName): void diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dd4bdf9..67a08f2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,9 @@ namespace App\Providers; +use App\Exports\CoreExportProvider; +use App\Integrations\ExportProviderRegistry; +use App\Integrations\ExportService; use App\Integrations\IntegrationTypeRegistry; use App\Integrations\TicketProviderRegistry; use App\Integrations\TicketService; @@ -35,10 +38,14 @@ public function register(): void $this->app->singleton(IntegrationTypeRegistry::class); $this->app->singleton(TicketProviderRegistry::class); $this->app->singleton(TicketService::class); + $this->app->singleton(ExportProviderRegistry::class); + $this->app->singleton(ExportService::class); } public function boot(): void { + $this->app->make(ExportProviderRegistry::class)->registerGlobal(CoreExportProvider::class); + $this->app->booted(function () { if (! Schema::hasTable('permissions')) { return; From 89ca16ddadabe97a05c327cbba196369175d9e98 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 9 Jul 2026 22:40:59 +0200 Subject: [PATCH 02/14] feat: add export-formats endpoint listing available exports The frontend export modal can now build its options dynamically instead of hardcoding the export types. --- .../Controllers/ExportFormatController.php | 15 +++++++++ app/Http/Resources/ExportFormat.php | 31 +++++++++++++++++++ routes/web.php | 3 ++ 3 files changed, 49 insertions(+) create mode 100644 app/Http/Controllers/ExportFormatController.php create mode 100644 app/Http/Resources/ExportFormat.php diff --git a/app/Http/Controllers/ExportFormatController.php b/app/Http/Controllers/ExportFormatController.php new file mode 100644 index 0000000..bf8a652 --- /dev/null +++ b/app/Http/Controllers/ExportFormatController.php @@ -0,0 +1,15 @@ +formats()); + } +} diff --git a/app/Http/Resources/ExportFormat.php b/app/Http/Resources/ExportFormat.php new file mode 100644 index 0000000..6433b59 --- /dev/null +++ b/app/Http/Resources/ExportFormat.php @@ -0,0 +1,31 @@ + $this->label, + 'dateRequirement' => $this->dateRequirement->value, + 'extension' => $this->extension, + ]; + } + + public function toType(Request $request): string + { + return 'exportFormats'; + } + + public function toId(Request $request): string + { + return $this->key; + } +} diff --git a/routes/web.php b/routes/web.php index ae011a0..40c4b8f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -21,6 +21,7 @@ use App\Http\Controllers\EntrySuggestionController; use App\Http\Controllers\EventController; use App\Http\Controllers\ExportEmailController; +use App\Http\Controllers\ExportFormatController; use App\Http\Controllers\Exports\GetBudgetEntriesExportController; use App\Http\Controllers\GetBudgetPeriodsController; use App\Http\Controllers\GetBudgetTimeSpentTotalsController; @@ -64,6 +65,8 @@ Route::get('daily-progress', GetDailyProgressController::class)->name('daily-progress'); + Route::get('export-formats', ExportFormatController::class)->name('export-formats.index'); + Route::get('budgets/export-mail', ExportEmailController::class)->name('budgets.export-mail'); Route::apiResource('budgets', BudgetController::class); From c0ab04bdd42c6bdfd8d0c68008ef373822979452 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 9 Jul 2026 22:48:47 +0200 Subject: [PATCH 03/14] feat: add Exact Globe integration with budget mutations CSV export Restores the Exact CSV export removed in the previous codebase, now as an integration package. The ledger id mapping moved from a hardcoded config file to a per-budget-type Filament page stored in the integration config, and the export only appears once the mapping is configured. --- composer.json | 5 + composer.lock | 30 +++ integrations/exact-globe/composer.json | 24 ++ .../pages/ledger-mapping-page.blade.php | 3 + .../exact-globe/src/BudgetsExactExport.php | 219 ++++++++++++++++++ .../src/DataTransferObjects/LedgerMapping.php | 26 +++ .../src/DataTransferObjects/MutationRow.php | 18 ++ .../src/ExactGlobeExportProvider.php | 59 +++++ .../src/Filament/Pages/LedgerMappingPage.php | 156 +++++++++++++ .../exact-globe/src/ServiceProvider.php | 29 +++ .../tests/BudgetsExactExportTest.php | 92 ++++++++ phpunit.xml | 3 + tests/Pest.php | 8 +- 13 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 integrations/exact-globe/composer.json create mode 100644 integrations/exact-globe/resources/views/filament/pages/ledger-mapping-page.blade.php create mode 100644 integrations/exact-globe/src/BudgetsExactExport.php create mode 100644 integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php create mode 100644 integrations/exact-globe/src/DataTransferObjects/MutationRow.php create mode 100644 integrations/exact-globe/src/ExactGlobeExportProvider.php create mode 100644 integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php create mode 100644 integrations/exact-globe/src/ServiceProvider.php create mode 100644 integrations/exact-globe/tests/BudgetsExactExportTest.php diff --git a/composer.json b/composer.json index 19fe31e..5d1e324 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,7 @@ "spatie/laravel-query-builder": "^6.3", "timacdonald/json-api": "dev-main as 1.0.0-beta.99", "timatic/bitbucket-integration": "1.0.0", + "timatic/exact-globe-integration": "1.0.0", "timatic/google-calendar-integration": "1.0.0", "timatic/jira-integration": "1.0.0", "timatic/nmbrs-integration": "*", @@ -58,6 +59,10 @@ "type": "path", "url": "integrations/jira" }, + { + "type": "path", + "url": "integrations/exact-globe" + }, { "type": "path", "url": "integrations/bitbucket" diff --git a/composer.lock b/composer.lock index 4bfb2a0..7040458 100644 --- a/composer.lock +++ b/composer.lock @@ -10760,6 +10760,36 @@ "relative": true } }, + { + "name": "timatic/exact-globe-integration", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "integrations/exact-globe", + "reference": "d0baa4d6750c782280b73d40ee1b05300a1b4dba" + }, + "require": { + "openspout/openspout": "^4.0", + "php": "^8.4" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Timatic\\ExactGlobe\\ServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Timatic\\ExactGlobe\\": "src/" + } + }, + "description": "Exact Globe export integration for Timatic", + "transport-options": { + "relative": true + } + }, { "name": "timatic/google-calendar-integration", "version": "1.0.0", diff --git a/integrations/exact-globe/composer.json b/integrations/exact-globe/composer.json new file mode 100644 index 0000000..789c98f --- /dev/null +++ b/integrations/exact-globe/composer.json @@ -0,0 +1,24 @@ +{ + "name": "timatic/exact-globe-integration", + "description": "Exact Globe export integration for Timatic", + "type": "library", + "version": "1.0.0", + "require": { + "php": "^8.4", + "openspout/openspout": "^4.0" + }, + "autoload": { + "psr-4": { + "Timatic\\ExactGlobe\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Timatic\\ExactGlobe\\ServiceProvider" + ] + } + }, + "minimum-stability": "stable", + "prefer-stable": true +} diff --git a/integrations/exact-globe/resources/views/filament/pages/ledger-mapping-page.blade.php b/integrations/exact-globe/resources/views/filament/pages/ledger-mapping-page.blade.php new file mode 100644 index 0000000..6d969da --- /dev/null +++ b/integrations/exact-globe/resources/views/filament/pages/ledger-mapping-page.blade.php @@ -0,0 +1,3 @@ + + {{ $this->form }} + diff --git a/integrations/exact-globe/src/BudgetsExactExport.php b/integrations/exact-globe/src/BudgetsExactExport.php new file mode 100644 index 0000000..3930a50 --- /dev/null +++ b/integrations/exact-globe/src/BudgetsExactExport.php @@ -0,0 +1,219 @@ + $ledgerMappings keyed by budget type id + */ + public function __construct( + int $year, + int $month, + private BudgetUsageService $usageService, + private Collection $ledgerMappings, + ) { + /** @var Carbon $firstOfMonth */ + $firstOfMonth = Carbon::create($year, $month, 1); + $this->month = $firstOfMonth; + } + + public function export(string $filePath): void + { + $writer = new Writer; + $writer->openToFile($filePath); + + $writer->addRow(Row::fromValues($this->headings())); + + foreach ($this->rows() as $row) { + $writer->addRow(Row::fromValues($this->map($row))); + } + + $writer->close(); + } + + /** + * @return list + */ + public function map(MutationRow $row): array + { + $amount = ($row->amount->isPositive() ? '+' : '').str_replace('.', ',', (string) $row->amount); + $lastDayOfMonth = $this->month->clone()->lastOfMonth()->format('tmY'); + $fullDescription = $row->description.' '.$this->month->format('F Y').' - '.$row->budgetId; + + return [ + $row->index, + 'M', // dagboekType + '90', // dagboekNr + $this->month->format('m'), + $this->month->format('Y'), + '', + $fullDescription, + $lastDayOfMonth, + $this->ledgerId($row), + $row->customerId ?? '', + '', + '', + $amount, + '', + 'EUR', + '1', + '', + '', + '', + '', + '0', + '0,00', + '', + '', + '', + '', + ($row->credit ? 10 : 20), // kostplaatsCode + '', + '0,00', + '', + '', + 'N', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + } + + /** + * @return list + */ + public function headings(): array + { + return [ + '0', + 'M', + '90', + $this->month->format('m'), + $this->month->format('Y'), + '', + '', + '', + '', + '', + '', + '', + '0,00', + '', + '', + '', + 'K', + '0,00', + '', + '', + '', + '', + '', + '', + 'B', + '', + '', + '', + '', + '', + '', + 'N', + '', + '', + '', + '', + '', + '', + '', + '', + ]; + } + + /** + * @return Collection + */ + public function rows(): Collection + { + /** @var Collection $rows */ + $rows = new Collection; + + $budgetUsage = (new Collection($this->usageService->get($this->month))) + ->filter(fn (BudgetMutation $usage) => $this->ledgerMappings->has($usage->budget->budget_type_id)); + + foreach ($budgetUsage as $budgetMutation) { + $this->addRowPairToCollection('Verbruik', $budgetMutation->usedCredit, $budgetMutation, $rows); + } + + foreach ($budgetUsage as $budgetMutation) { + $this->addRowPairToCollection('Vrijval', $budgetMutation->expiredCredit, $budgetMutation, $rows); + } + + return $rows; + } + + private function ledgerId(MutationRow $row): string + { + /** @var LedgerMapping $mapping */ + $mapping = $this->ledgerMappings->get($row->budgetTypeId); + + return match ($row->description) { + 'Verbruik' => $row->credit ? $mapping->verbruikCreditLedgerId : $mapping->verbruikDebitLedgerId, + default => $row->credit ? $mapping->vrijvalCreditLedgerId : $mapping->vrijvalDebitLedgerId, + }; + } + + /** + * @param Collection $rows + */ + private function addRowPairToCollection( + string $description, + BigDecimal $amount, + BudgetMutation $budgetMutation, + Collection $rows, + ): void { + if ($amount->isEqualTo(0)) { + return; + } + + $creditRow = new MutationRow( + index: $rows->count() + 1, + description: $description, + amount: $amount, + customerId: $budgetMutation->budget->customer?->external_id, + budgetTypeId: $budgetMutation->budget->budget_type_id, + budgetId: $budgetMutation->budget->id, + credit: true, + ); + + $debitRow = new MutationRow( + index: $rows->count() + 2, + description: $description, + amount: $amount->multipliedBy(-1), + customerId: $creditRow->customerId, + budgetTypeId: $creditRow->budgetTypeId, + budgetId: $creditRow->budgetId, + credit: false, + ); + + $rows->push($creditRow); + $rows->push($debitRow); + } +} diff --git a/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php b/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php new file mode 100644 index 0000000..809167f --- /dev/null +++ b/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php @@ -0,0 +1,26 @@ + $row */ + public static function fromConfigRow(string $budgetTypeId, array $row): self + { + return new self( + budgetTypeId: $budgetTypeId, + verbruikCreditLedgerId: (string) ($row['verbruik_credit'] ?? ''), + verbruikDebitLedgerId: (string) ($row['verbruik_debit'] ?? ''), + vrijvalCreditLedgerId: (string) ($row['vrijval_credit'] ?? ''), + vrijvalDebitLedgerId: (string) ($row['vrijval_debit'] ?? ''), + ); + } +} diff --git a/integrations/exact-globe/src/DataTransferObjects/MutationRow.php b/integrations/exact-globe/src/DataTransferObjects/MutationRow.php new file mode 100644 index 0000000..b3f2bb7 --- /dev/null +++ b/integrations/exact-globe/src/DataTransferObjects/MutationRow.php @@ -0,0 +1,18 @@ + $ledgerMappings keyed by budget type id + */ + public function __construct(private readonly Collection $ledgerMappings) {} + + public function exportFormats(): Collection + { + if ($this->ledgerMappings->isEmpty()) { + return new Collection; + } + + return new Collection([ + new ExportFormat(self::EXPORT_KEY, 'Budget mutations - Exact CSV', ExportDateRequirement::Monthly, 'csv'), + ]); + } + + public function createExport(string $key, ExportPeriod $period): ExportInterface + { + if ($key !== self::EXPORT_KEY) { + throw new InvalidArgumentException("Unknown export format: {$key}"); + } + + return new BudgetsExactExport( + $period->year, + $period->requireMonth(), + app(BudgetUsageService::class), + $this->ledgerMappings, + ); + } + + public static function fromConfig(array $config): static + { + /** @var array> $mappingRows */ + $mappingRows = $config['ledger_mapping'] ?? []; + + $ledgerMappings = (new Collection($mappingRows)) + ->map(fn (array $row, string $budgetTypeId) => LedgerMapping::fromConfigRow($budgetTypeId, $row)); + + return new self($ledgerMappings); + } +} diff --git a/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php new file mode 100644 index 0000000..8020b13 --- /dev/null +++ b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php @@ -0,0 +1,156 @@ + */ + public array $data = []; + + public function getTitle(): string + { + return $this->getIntegration()->name; + } + + public static function getSubNavigationPosition(): SubNavigationPosition + { + return SubNavigationPosition::Start; + } + + public function getSubNavigation(): array + { + $record = $this->getRecord(); + + return [ + NavigationItem::make('Ledger mapping') + ->url(LedgerMappingPage::getUrl(['record' => $record])) + ->isActiveWhen(fn () => request()->url() === LedgerMappingPage::getUrl(['record' => $record])), + ]; + } + + public function mount(int|string $record): void + { + $this->record = $this->resolveRecord($record); + + $config = $this->getIntegration()->config ?? []; + + $this->form->fill([ + 'name' => $this->getIntegration()->name, + 'ledger_mapping' => $config['ledger_mapping'] ?? [], + ]); + } + + public function form(Schema $form): Schema + { + return $form->schema([ + TextInput::make('name') + ->label('Integration name') + ->required(), + + ...$this->budgetTypeSections(), + ])->statePath('data'); + } + + protected function getHeaderActions(): array + { + return [ + Action::make('delete') + ->label('Delete') + ->color('danger') + ->requiresConfirmation() + ->action(function (): void { + $this->getIntegration()->delete(); + $this->redirect(IntegrationResource::getUrl('index')); + }), + + Action::make('save') + ->label('Save') + ->action(function (): void { + $data = $this->form->getState(); + + $config = array_merge($this->getIntegration()->config ?? [], [ + 'ledger_mapping' => $this->completedMappingRows($data['ledger_mapping'] ?? []), + ]); + + $this->getIntegration()->update([ + 'name' => $data['name'], + 'config' => $config, + ]); + + Notification::make()->title('Ledger mapping saved.')->success()->send(); + }), + ]; + } + + /** + * @return list
+ */ + private function budgetTypeSections(): array + { + return array_values(BudgetType::query() + ->where('is_archived', false) + ->orderBy('title') + ->get() + ->map(fn (BudgetType $budgetType): Section => Section::make($budgetType->title) + ->description('Ledger accounts for '.$budgetType->title.' budgets. Leave empty to exclude this budget type from the export.') + ->columns(2) + ->schema([ + TextInput::make("ledger_mapping.{$budgetType->id}.verbruik_credit") + ->label('Verbruik credit ledger') + ->numeric(), + TextInput::make("ledger_mapping.{$budgetType->id}.verbruik_debit") + ->label('Verbruik debit ledger') + ->numeric(), + TextInput::make("ledger_mapping.{$budgetType->id}.vrijval_credit") + ->label('Vrijval credit ledger') + ->numeric(), + TextInput::make("ledger_mapping.{$budgetType->id}.vrijval_debit") + ->label('Vrijval debit ledger') + ->numeric(), + ])) + ->all()); + } + + /** + * @param array> $rows + * @return array> + */ + private function completedMappingRows(array $rows): array + { + return array_filter( + $rows, + fn (array $row): bool => filled($row['verbruik_credit'] ?? null) + && filled($row['verbruik_debit'] ?? null) + && filled($row['vrijval_credit'] ?? null) + && filled($row['vrijval_debit'] ?? null), + ); + } + + private function getIntegration(): Integration + { + /** @var Integration */ + return Integration::findOrFail($this->getRecord()->getKey()); + } +} diff --git a/integrations/exact-globe/src/ServiceProvider.php b/integrations/exact-globe/src/ServiceProvider.php new file mode 100644 index 0000000..9a82487 --- /dev/null +++ b/integrations/exact-globe/src/ServiceProvider.php @@ -0,0 +1,29 @@ +callAfterResolving(IntegrationTypeRegistry::class, function (IntegrationTypeRegistry $types): void { + $types->register(self::INTEGRATION_TYPE, [ + 'exact-globe.ledger-mapping' => LedgerMappingPage::class, + ]); + }); + } + + public function boot(ExportProviderRegistry $exportProviders): void + { + $exportProviders->register(self::INTEGRATION_TYPE, ExactGlobeExportProvider::class); + + $this->loadViewsFrom(__DIR__.'/../resources/views', self::INTEGRATION_TYPE); + } +} diff --git a/integrations/exact-globe/tests/BudgetsExactExportTest.php b/integrations/exact-globe/tests/BudgetsExactExportTest.php new file mode 100644 index 0000000..4fb8bf5 --- /dev/null +++ b/integrations/exact-globe/tests/BudgetsExactExportTest.php @@ -0,0 +1,92 @@ +for(Customer::factory()->state(['external_id' => 'CUST-42'])) + ->create(['budget_type_id' => 'project']); + + $mutation = new BudgetMutation($budget); + $mutation->usedCredit = BigDecimal::of('10.5'); + $mutation->expiredCredit = BigDecimal::of('2'); + + $usageService = mock(BudgetUsageService::class); + $usageService->shouldReceive('get')->andReturn(new EloquentCollection([$mutation])); + + $ledgerMappings = new Collection([ + 'project' => new LedgerMapping('project', '1001', '1002', '2001', '2002'), + ]); + + $filePath = tempnam(sys_get_temp_dir(), 'exact').'.csv'; + (new BudgetsExactExport(2026, 6, $usageService, $ledgerMappings))->export($filePath); + + $rows = array_map('str_getcsv', file($filePath)); + + expect($rows)->toHaveCount(5); + [$verbruikCredit, $verbruikDebit, $vrijvalCredit, $vrijvalDebit] = array_slice($rows, 1); + expect($verbruikCredit[8])->toBe('1001') + ->and($verbruikCredit[12])->toBe('+10,5') + ->and($verbruikCredit[9])->toBe('CUST-42') + ->and($verbruikDebit[8])->toBe('1002') + ->and($verbruikDebit[12])->toBe('-10,5') + ->and($vrijvalCredit[8])->toBe('2001') + ->and($vrijvalCredit[12])->toBe('+2') + ->and($vrijvalDebit[8])->toBe('2002') + ->and($vrijvalDebit[12])->toBe('-2'); +}); + +it('skips budgets whose type has no ledger mapping', function () { + $budget = Budget::factory()->create(['budget_type_id' => 'support']); + + $mutation = new BudgetMutation($budget); + $mutation->usedCredit = BigDecimal::of('8'); + + $usageService = mock(BudgetUsageService::class); + $usageService->shouldReceive('get')->andReturn(new EloquentCollection([$mutation])); + + $ledgerMappings = new Collection([ + 'project' => new LedgerMapping('project', '1001', '1002', '2001', '2002'), + ]); + + $filePath = tempnam(sys_get_temp_dir(), 'exact').'.csv'; + (new BudgetsExactExport(2026, 6, $usageService, $ledgerMappings))->export($filePath); + + expect(file($filePath))->toHaveCount(1); +}); + +it('builds ledger mappings from the integration config', function () { + $provider = ExactGlobeExportProvider::fromConfig([ + 'ledger_mapping' => [ + 'project' => [ + 'verbruik_credit' => '1001', + 'verbruik_debit' => '1002', + 'vrijval_credit' => '2001', + 'vrijval_debit' => '2002', + ], + ], + ]); + + expect($provider->exportFormats())->toHaveCount(1) + ->and($provider->exportFormats()->first()->key)->toBe('exact-globe-csv'); +}); + +it('exposes no export formats without a ledger mapping', function () { + $provider = ExactGlobeExportProvider::fromConfig([]); + + expect($provider->exportFormats())->toBeEmpty(); +}); diff --git a/phpunit.xml b/phpunit.xml index a89c019..5d64d72 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -14,6 +14,9 @@ ./integrations/nmbrs/tests + + ./integrations/exact-globe/tests + diff --git a/tests/Pest.php b/tests/Pest.php index 9fc3988..dffee1b 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -20,7 +20,13 @@ ->beforeEach(function () { Config::preventStrayRequests(); }) - ->in('Feature', 'Integration', 'Unit', '../integrations/nmbrs/tests'); + ->in( + 'Feature', + 'Integration', + 'Unit', + '../integrations/nmbrs/tests', + '../integrations/exact-globe/tests' + ); /* |-------------------------------------------------------------------------- From b897dd19be8d460d38dac08602c73b0f79dd91cc Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 9 Jul 2026 22:48:47 +0200 Subject: [PATCH 04/14] test: cover export formats endpoint and dynamic export validation --- tests/Feature/Exports/ExportFormatsTest.php | 70 +++++++++++++++++++ .../Exports/ExportRequestValidationTest.php | 38 ++++++++++ tests/Unit/Integrations/ExportServiceTest.php | 21 ++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/Feature/Exports/ExportFormatsTest.php create mode 100644 tests/Feature/Exports/ExportRequestValidationTest.php create mode 100644 tests/Unit/Integrations/ExportServiceTest.php diff --git a/tests/Feature/Exports/ExportFormatsTest.php b/tests/Feature/Exports/ExportFormatsTest.php new file mode 100644 index 0000000..a61e125 --- /dev/null +++ b/tests/Feature/Exports/ExportFormatsTest.php @@ -0,0 +1,70 @@ +loginUser(); + + $response = $this->getJson(route('export-formats.index')); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'id' => 'budgets-monthly-excel', + 'type' => 'exportFormats', + 'attributes' => [ + 'label' => 'Budget mutations - Excel', + 'dateRequirement' => 'monthly', + 'extension' => 'xlsx', + ], + ]); + expect(collect($response->json('data'))->pluck('id')->all())->toBe([ + 'budgets-monthly-excel', + 'budgets-excel', + 'entries-excel', + 'users-monthly-summary-excel', + ]); +}); + +it('lists the exact globe export when the integration is configured', function () { + $this->loginUser(); + Integration::create([ + 'name' => 'Exact Globe', + 'type' => 'exact-globe', + 'config' => [ + 'ledger_mapping' => [ + 'project' => [ + 'verbruik_credit' => '28075', + 'verbruik_debit' => '81135', + 'vrijval_credit' => '28075', + 'vrijval_debit' => '81131', + ], + ], + ], + ]); + + $response = $this->getJson(route('export-formats.index')); + + $response->assertSuccessful(); + $response->assertJsonFragment([ + 'id' => 'exact-globe-csv', + 'type' => 'exportFormats', + 'attributes' => [ + 'label' => 'Budget mutations - Exact CSV', + 'dateRequirement' => 'monthly', + 'extension' => 'csv', + ], + ]); +}); + +it('hides the exact globe export when the integration has no ledger mapping', function () { + $this->loginUser(); + Integration::create(['name' => 'Exact Globe', 'type' => 'exact-globe', 'config' => []]); + + $response = $this->getJson(route('export-formats.index')); + + $response->assertSuccessful(); + expect(collect($response->json('data'))->pluck('id')->all())->not->toContain('exact-globe-csv'); +}); diff --git a/tests/Feature/Exports/ExportRequestValidationTest.php b/tests/Feature/Exports/ExportRequestValidationTest.php new file mode 100644 index 0000000..ac55ab0 --- /dev/null +++ b/tests/Feature/Exports/ExportRequestValidationTest.php @@ -0,0 +1,38 @@ +loginUser(); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'unknown-export', + 'year' => 2024, + 'month' => 10, + ]))->assertUnprocessable()->assertJsonValidationErrors('exportType'); +}); + +it('rejects a monthly export without a month', function () { + $this->loginUser(); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'budgets-monthly-excel', + 'year' => 2024, + ]))->assertUnprocessable()->assertJsonValidationErrors('month'); +}); + +it('accepts a monthly and yearly export without a month', function () { + $this->loginUser(); + Mail::fake(); + Storage::fake('s3'); + Storage::fake('temp'); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'entries-excel', + 'year' => 2024, + ]))->assertStatus(202); +}); diff --git a/tests/Unit/Integrations/ExportServiceTest.php b/tests/Unit/Integrations/ExportServiceTest.php new file mode 100644 index 0000000..048d06f --- /dev/null +++ b/tests/Unit/Integrations/ExportServiceTest.php @@ -0,0 +1,21 @@ +registerGlobal(CoreExportProvider::class); + $registry->registerGlobal(CoreExportProvider::class); + + (new ExportService($registry))->formats(); +})->throws(RuntimeException::class, 'Duplicate export format keys'); + +it('throws when creating an export for an unknown format', function () { + $registry = new ExportProviderRegistry; + $registry->registerGlobal(CoreExportProvider::class); + + (new ExportService($registry))->createExport('unknown-format', new ExportPeriod(2026, 6)); +})->throws(InvalidArgumentException::class, 'Unknown export format: unknown-format'); From 8dae551e83ef9a5467695a3683e8be571d693a77 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 14:17:00 +0200 Subject: [PATCH 05/14] feat: configurable filesystem for exports --- app/Http/Controllers/ExportEmailController.php | 1 - app/Jobs/ExportBudgetsJob.php | 6 +++--- tests/Feature/Exports/BudgetExportTest.php | 4 ++-- tests/Feature/Exports/ExportMailTest.php | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/Http/Controllers/ExportEmailController.php b/app/Http/Controllers/ExportEmailController.php index 3a604a5..3b4a01a 100644 --- a/app/Http/Controllers/ExportEmailController.php +++ b/app/Http/Controllers/ExportEmailController.php @@ -42,6 +42,5 @@ public function download(string $fileName) } return Storage::download($fileName); - } } diff --git a/app/Jobs/ExportBudgetsJob.php b/app/Jobs/ExportBudgetsJob.php index 28b87cf..94b54a0 100644 --- a/app/Jobs/ExportBudgetsJob.php +++ b/app/Jobs/ExportBudgetsJob.php @@ -35,11 +35,11 @@ public function handle(ExportService $exportService): void ->createExport($this->exportType, new ExportPeriod($this->year, $this->month)) ->export(Storage::disk('temp')->path($fileName)); - $this->uploadToS3($fileName); + $this->storeExport($fileName); $this->sendExportEmail($fileName); } - private function uploadToS3(string $fileName): void + private function storeExport(string $fileName): void { $content = Storage::disk('temp')->get($fileName); @@ -47,7 +47,7 @@ private function uploadToS3(string $fileName): void throw new InvalidArgumentException("File {$fileName} does not exist"); } - Storage::disk('s3')->put($fileName, $content); + Storage::put($fileName, $content); } private function sendExportEmail(string $fileName): void diff --git a/tests/Feature/Exports/BudgetExportTest.php b/tests/Feature/Exports/BudgetExportTest.php index 654f9dc..1e20007 100644 --- a/tests/Feature/Exports/BudgetExportTest.php +++ b/tests/Feature/Exports/BudgetExportTest.php @@ -98,7 +98,7 @@ it('exports and allows downloading of budget data', function () { Mail::fake(); Event::fake(); - Storage::fake('local'); + Storage::fake(); $this->loginUser(permissions: ['user']); @@ -130,7 +130,7 @@ expect($rowCount)->toEqual(2); $fileName = 'export_budgets-excel_2023_7.xlsx'; - Storage::put($fileName, 'Sample export content'); // Optional mock content + Storage::assertExists($fileName); $downloadResponse = $this->get(route('download.export', ['fileName' => $fileName])); diff --git a/tests/Feature/Exports/ExportMailTest.php b/tests/Feature/Exports/ExportMailTest.php index 849cc97..f2a5619 100644 --- a/tests/Feature/Exports/ExportMailTest.php +++ b/tests/Feature/Exports/ExportMailTest.php @@ -14,7 +14,7 @@ $user = User::factory()->create(); $this->actingAs($user); - Storage::fake('s3'); + Storage::fake(); Mail::fake(); From ee59c7acd6bcd0838756eb6ed05dc66df030d4d9 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 14:39:15 +0200 Subject: [PATCH 06/14] refactor: rename ExportDateRequirement to ExportPeriodOptions The enum lists which period granularities an export offers rather than whether a date is required, and the periodOptions attribute in the export-formats endpoint now reflects that. --- app/DataTransferObjects/ExportFormat.php | 4 ++-- ...portDateRequirement.php => ExportPeriodOptions.php} | 2 +- app/Exports/CoreExportProvider.php | 10 +++++----- app/Http/Requests/ExportRequest.php | 4 ++-- app/Http/Resources/ExportFormat.php | 2 +- .../exact-globe/src/ExactGlobeExportProvider.php | 4 ++-- tests/Feature/Exports/ExportFormatsTest.php | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) rename app/Enums/{ExportDateRequirement.php => ExportPeriodOptions.php} (79%) diff --git a/app/DataTransferObjects/ExportFormat.php b/app/DataTransferObjects/ExportFormat.php index b897ae3..97ac674 100644 --- a/app/DataTransferObjects/ExportFormat.php +++ b/app/DataTransferObjects/ExportFormat.php @@ -2,14 +2,14 @@ namespace App\DataTransferObjects; -use App\Enums\ExportDateRequirement; +use App\Enums\ExportPeriodOptions; readonly class ExportFormat { public function __construct( public string $key, public string $label, - public ExportDateRequirement $dateRequirement, + public ExportPeriodOptions $periodOptions, public string $extension = 'xlsx', ) {} } diff --git a/app/Enums/ExportDateRequirement.php b/app/Enums/ExportPeriodOptions.php similarity index 79% rename from app/Enums/ExportDateRequirement.php rename to app/Enums/ExportPeriodOptions.php index 4af1eb7..0552b3b 100644 --- a/app/Enums/ExportDateRequirement.php +++ b/app/Enums/ExportPeriodOptions.php @@ -2,7 +2,7 @@ namespace App\Enums; -enum ExportDateRequirement: string +enum ExportPeriodOptions: string { case None = 'none'; case Monthly = 'monthly'; diff --git a/app/Exports/CoreExportProvider.php b/app/Exports/CoreExportProvider.php index 82643a1..3385e88 100644 --- a/app/Exports/CoreExportProvider.php +++ b/app/Exports/CoreExportProvider.php @@ -4,7 +4,7 @@ use App\DataTransferObjects\ExportFormat; use App\DataTransferObjects\ExportPeriod; -use App\Enums\ExportDateRequirement; +use App\Enums\ExportPeriodOptions; use App\Integrations\Contracts\ExportInterface; use App\Integrations\Contracts\ExportProviderInterface; use App\Services\BudgetUsageService; @@ -16,10 +16,10 @@ final class CoreExportProvider implements ExportProviderInterface public function exportFormats(): Collection { return collect([ - new ExportFormat('budgets-monthly-excel', 'Budget mutations - Excel', ExportDateRequirement::Monthly), - new ExportFormat('budgets-excel', 'Budgets current balance', ExportDateRequirement::None), - new ExportFormat('entries-excel', 'Entries dump', ExportDateRequirement::MonthlyAndYearly), - new ExportFormat('users-monthly-summary-excel', 'Monthly summary per user', ExportDateRequirement::Monthly), + new ExportFormat('budgets-monthly-excel', 'Budget mutations - Excel', ExportPeriodOptions::Monthly), + new ExportFormat('budgets-excel', 'Budgets current balance', ExportPeriodOptions::None), + new ExportFormat('entries-excel', 'Entries dump', ExportPeriodOptions::MonthlyAndYearly), + new ExportFormat('users-monthly-summary-excel', 'Monthly summary per user', ExportPeriodOptions::Monthly), ]); } diff --git a/app/Http/Requests/ExportRequest.php b/app/Http/Requests/ExportRequest.php index 26df62b..29fbd91 100644 --- a/app/Http/Requests/ExportRequest.php +++ b/app/Http/Requests/ExportRequest.php @@ -2,7 +2,7 @@ namespace App\Http\Requests; -use App\Enums\ExportDateRequirement; +use App\Enums\ExportPeriodOptions; use App\Integrations\ExportService; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -31,7 +31,7 @@ public function rules(ExportService $exportService): array 'exportType' => ['required', 'string', Rule::in($exportService->formatKeys())], 'year' => ['required', 'integer', 'min:2000', 'max:'.date('Y')], 'month' => [ - Rule::requiredIf($format?->dateRequirement === ExportDateRequirement::Monthly), + Rule::requiredIf($format?->periodOptions === ExportPeriodOptions::Monthly), 'nullable', 'integer', 'min:1', 'max:12', ], ]; diff --git a/app/Http/Resources/ExportFormat.php b/app/Http/Resources/ExportFormat.php index 6433b59..10309f8 100644 --- a/app/Http/Resources/ExportFormat.php +++ b/app/Http/Resources/ExportFormat.php @@ -14,7 +14,7 @@ public function toAttributes(Request $request): array { return [ 'label' => $this->label, - 'dateRequirement' => $this->dateRequirement->value, + 'periodOptions' => $this->periodOptions->value, 'extension' => $this->extension, ]; } diff --git a/integrations/exact-globe/src/ExactGlobeExportProvider.php b/integrations/exact-globe/src/ExactGlobeExportProvider.php index e948d6b..3aa297a 100644 --- a/integrations/exact-globe/src/ExactGlobeExportProvider.php +++ b/integrations/exact-globe/src/ExactGlobeExportProvider.php @@ -4,7 +4,7 @@ use App\DataTransferObjects\ExportFormat; use App\DataTransferObjects\ExportPeriod; -use App\Enums\ExportDateRequirement; +use App\Enums\ExportPeriodOptions; use App\Integrations\Contracts\ExportInterface; use App\Integrations\Contracts\ExportProviderInterface; use App\Services\BudgetUsageService; @@ -28,7 +28,7 @@ public function exportFormats(): Collection } return new Collection([ - new ExportFormat(self::EXPORT_KEY, 'Budget mutations - Exact CSV', ExportDateRequirement::Monthly, 'csv'), + new ExportFormat(self::EXPORT_KEY, 'Budget mutations - Exact CSV', ExportPeriodOptions::Monthly, 'csv'), ]); } diff --git a/tests/Feature/Exports/ExportFormatsTest.php b/tests/Feature/Exports/ExportFormatsTest.php index a61e125..b4b18d4 100644 --- a/tests/Feature/Exports/ExportFormatsTest.php +++ b/tests/Feature/Exports/ExportFormatsTest.php @@ -16,7 +16,7 @@ 'type' => 'exportFormats', 'attributes' => [ 'label' => 'Budget mutations - Excel', - 'dateRequirement' => 'monthly', + 'periodOptions' => 'monthly', 'extension' => 'xlsx', ], ]); @@ -53,7 +53,7 @@ 'type' => 'exportFormats', 'attributes' => [ 'label' => 'Budget mutations - Exact CSV', - 'dateRequirement' => 'monthly', + 'periodOptions' => 'monthly', 'extension' => 'csv', ], ]); From 15269ad1b0a57c324e9d7ba46120b4ec486b521d Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 14:47:29 +0200 Subject: [PATCH 07/14] rename export labels --- app/Exports/CoreExportProvider.php | 2 +- integrations/exact-globe/src/ExactGlobeExportProvider.php | 2 +- tests/Feature/Exports/ExportFormatsTest.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Exports/CoreExportProvider.php b/app/Exports/CoreExportProvider.php index 3385e88..0d0e274 100644 --- a/app/Exports/CoreExportProvider.php +++ b/app/Exports/CoreExportProvider.php @@ -16,7 +16,7 @@ final class CoreExportProvider implements ExportProviderInterface public function exportFormats(): Collection { return collect([ - new ExportFormat('budgets-monthly-excel', 'Budget mutations - Excel', ExportPeriodOptions::Monthly), + new ExportFormat('budgets-monthly-excel', 'Budget mutations', ExportPeriodOptions::Monthly), new ExportFormat('budgets-excel', 'Budgets current balance', ExportPeriodOptions::None), new ExportFormat('entries-excel', 'Entries dump', ExportPeriodOptions::MonthlyAndYearly), new ExportFormat('users-monthly-summary-excel', 'Monthly summary per user', ExportPeriodOptions::Monthly), diff --git a/integrations/exact-globe/src/ExactGlobeExportProvider.php b/integrations/exact-globe/src/ExactGlobeExportProvider.php index 3aa297a..ae71962 100644 --- a/integrations/exact-globe/src/ExactGlobeExportProvider.php +++ b/integrations/exact-globe/src/ExactGlobeExportProvider.php @@ -28,7 +28,7 @@ public function exportFormats(): Collection } return new Collection([ - new ExportFormat(self::EXPORT_KEY, 'Budget mutations - Exact CSV', ExportPeriodOptions::Monthly, 'csv'), + new ExportFormat(self::EXPORT_KEY, 'Budget mutations - Exact', ExportPeriodOptions::Monthly, 'csv'), ]); } diff --git a/tests/Feature/Exports/ExportFormatsTest.php b/tests/Feature/Exports/ExportFormatsTest.php index b4b18d4..cde575c 100644 --- a/tests/Feature/Exports/ExportFormatsTest.php +++ b/tests/Feature/Exports/ExportFormatsTest.php @@ -15,7 +15,7 @@ 'id' => 'budgets-monthly-excel', 'type' => 'exportFormats', 'attributes' => [ - 'label' => 'Budget mutations - Excel', + 'label' => 'Budget mutations', 'periodOptions' => 'monthly', 'extension' => 'xlsx', ], @@ -52,7 +52,7 @@ 'id' => 'exact-globe-csv', 'type' => 'exportFormats', 'attributes' => [ - 'label' => 'Budget mutations - Exact CSV', + 'label' => 'Budget mutations - Exact', 'periodOptions' => 'monthly', 'extension' => 'csv', ], From 38373ac8c14ef034f935fe1419e090697405ba69 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 15:43:07 +0200 Subject: [PATCH 08/14] fix: only require year for exports that cover a period Exports without period options (like the current balance export) no longer demand a meaningless year parameter; their filename and mail drop the period as well. --- app/DataTransferObjects/ExportPeriod.php | 15 ++++++++------- app/Exports/CoreExportProvider.php | 2 +- app/Http/Controllers/ExportEmailController.php | 2 +- app/Http/Requests/ExportRequest.php | 5 ++++- app/Jobs/ExportBudgetsJob.php | 13 +++++++++++-- app/Mail/ExportEmail.php | 6 ++++-- .../exact-globe/src/ExactGlobeExportProvider.php | 2 +- resources/views/mail/budgets/export.blade.php | 2 ++ .../Exports/ExportRequestValidationTest.php | 11 +++++++++++ 9 files changed, 43 insertions(+), 15 deletions(-) diff --git a/app/DataTransferObjects/ExportPeriod.php b/app/DataTransferObjects/ExportPeriod.php index 130cbe8..f0438b3 100644 --- a/app/DataTransferObjects/ExportPeriod.php +++ b/app/DataTransferObjects/ExportPeriod.php @@ -8,17 +8,13 @@ readonly class ExportPeriod { public function __construct( - public int $year, + public ?int $year, public ?int $month, - ) { - if (CarbonImmutable::create($this->year, $this->month ?? 1, 1) === null) { - throw new InvalidArgumentException("Invalid date for year {$this->year} and month {$this->month}"); - } - } + ) {} public function start(): CarbonImmutable { - $start = CarbonImmutable::create($this->year, $this->month ?? 1, 1); + $start = CarbonImmutable::create($this->requireYear(), $this->month ?? 1, 1); assert($start !== null); @@ -32,6 +28,11 @@ public function end(): CarbonImmutable : $this->start()->endOfMonth(); } + public function requireYear(): int + { + return $this->year ?? throw new InvalidArgumentException('This export requires a year.'); + } + public function requireMonth(): int { return $this->month ?? throw new InvalidArgumentException('This export requires a month.'); diff --git a/app/Exports/CoreExportProvider.php b/app/Exports/CoreExportProvider.php index 0d0e274..91230ac 100644 --- a/app/Exports/CoreExportProvider.php +++ b/app/Exports/CoreExportProvider.php @@ -26,7 +26,7 @@ public function exportFormats(): Collection public function createExport(string $key, ExportPeriod $period): ExportInterface { return match ($key) { - 'budgets-monthly-excel' => new MonthlyBudgetsExport($period->year, $period->requireMonth(), app(BudgetUsageService::class)), + 'budgets-monthly-excel' => new MonthlyBudgetsExport($period->requireYear(), $period->requireMonth(), app(BudgetUsageService::class)), 'budgets-excel' => new BudgetsExport, 'entries-excel' => new EntriesExport($period->start(), $period->end()), 'users-monthly-summary-excel' => new UsersMonthlySummaryExport($period->start(), $period->end()), diff --git a/app/Http/Controllers/ExportEmailController.php b/app/Http/Controllers/ExportEmailController.php index 3b4a01a..cb54297 100644 --- a/app/Http/Controllers/ExportEmailController.php +++ b/app/Http/Controllers/ExportEmailController.php @@ -23,7 +23,7 @@ public function __invoke(ExportRequest $request, #[CurrentUser] User $user) $validated = $request->validated(); $exportType = $validated['exportType']; - $year = $validated['year']; + $year = $validated['year'] ?? null; $month = $validated['month'] ?? null; ExportBudgetsJob::dispatch($user, $exportType, $year, $month); diff --git a/app/Http/Requests/ExportRequest.php b/app/Http/Requests/ExportRequest.php index 29fbd91..a63f004 100644 --- a/app/Http/Requests/ExportRequest.php +++ b/app/Http/Requests/ExportRequest.php @@ -29,7 +29,10 @@ public function rules(ExportService $exportService): array return [ 'exportType' => ['required', 'string', Rule::in($exportService->formatKeys())], - 'year' => ['required', 'integer', 'min:2000', 'max:'.date('Y')], + 'year' => [ + Rule::requiredIf($format !== null && $format->periodOptions !== ExportPeriodOptions::None), + 'nullable', 'integer', 'min:2000', 'max:'.date('Y'), + ], 'month' => [ Rule::requiredIf($format?->periodOptions === ExportPeriodOptions::Monthly), 'nullable', 'integer', 'min:1', 'max:12', diff --git a/app/Jobs/ExportBudgetsJob.php b/app/Jobs/ExportBudgetsJob.php index 94b54a0..4c9f4ff 100644 --- a/app/Jobs/ExportBudgetsJob.php +++ b/app/Jobs/ExportBudgetsJob.php @@ -20,7 +20,7 @@ class ExportBudgetsJob implements ShouldQueue public function __construct( protected User $user, protected string $exportType, - protected int $year, + protected ?int $year, protected ?int $month, ) {} @@ -29,7 +29,7 @@ public function handle(ExportService $exportService): void $format = $exportService->findFormat($this->exportType) ?? throw new InvalidArgumentException("Invalid export type: {$this->exportType}"); - $fileName = "export_{$this->exportType}_{$this->year}_".($this->month ?? 'all').".{$format->extension}"; + $fileName = $this->generateFileName($format->extension); $exportService ->createExport($this->exportType, new ExportPeriod($this->year, $this->month)) @@ -39,6 +39,15 @@ public function handle(ExportService $exportService): void $this->sendExportEmail($fileName); } + private function generateFileName(string $extension): string + { + if ($this->year === null) { + return "export_{$this->exportType}.{$extension}"; + } + + return "export_{$this->exportType}_{$this->year}_".($this->month ?? 'all').".{$extension}"; + } + private function storeExport(string $fileName): void { $content = Storage::disk('temp')->get($fileName); diff --git a/app/Mail/ExportEmail.php b/app/Mail/ExportEmail.php index 65df6c0..8c3a7c9 100644 --- a/app/Mail/ExportEmail.php +++ b/app/Mail/ExportEmail.php @@ -19,13 +19,15 @@ class ExportEmail extends Mailable public string $formattedDate; - public function __construct(string $fileName, int $year, ?int $month, string $exportType) + public function __construct(string $fileName, ?int $year, ?int $month, string $exportType) { $this->fileName = $fileName; $this->exportType = $exportType; $this->downloadUrl = route('download.export', ['fileName' => $this->fileName]); - if (is_null($month)) { + if (is_null($year)) { + $this->formattedDate = ''; + } elseif (is_null($month)) { $this->formattedDate = (string) $year; } else { $this->formattedDate = Carbon::createFromDate($year, $month, 1) diff --git a/integrations/exact-globe/src/ExactGlobeExportProvider.php b/integrations/exact-globe/src/ExactGlobeExportProvider.php index ae71962..10497d1 100644 --- a/integrations/exact-globe/src/ExactGlobeExportProvider.php +++ b/integrations/exact-globe/src/ExactGlobeExportProvider.php @@ -39,7 +39,7 @@ public function createExport(string $key, ExportPeriod $period): ExportInterface } return new BudgetsExactExport( - $period->year, + $period->requireYear(), $period->requireMonth(), app(BudgetUsageService::class), $this->ledgerMappings, diff --git a/resources/views/mail/budgets/export.blade.php b/resources/views/mail/budgets/export.blade.php index 1275b23..1597cd5 100644 --- a/resources/views/mail/budgets/export.blade.php +++ b/resources/views/mail/budgets/export.blade.php @@ -6,7 +6,9 @@ ## {{__('File Details:')}} - {{__('Export Type:')}} {{$exportType}} +@if($formattedDate !== '') - {{__('Period:')}} {{ $formattedDate }} +@endif - {{__('File Name:')}} {{$fileName}} {{__('You can download your file by clicking the button below:')}} diff --git a/tests/Feature/Exports/ExportRequestValidationTest.php b/tests/Feature/Exports/ExportRequestValidationTest.php index ac55ab0..a3922ab 100644 --- a/tests/Feature/Exports/ExportRequestValidationTest.php +++ b/tests/Feature/Exports/ExportRequestValidationTest.php @@ -25,6 +25,17 @@ ]))->assertUnprocessable()->assertJsonValidationErrors('month'); }); +it('accepts an export without period options without a year and month', function () { + $this->loginUser(); + Mail::fake(); + Storage::fake(); + Storage::fake('temp'); + + $this->getJson(route('budgets.export-mail', [ + 'exportType' => 'budgets-excel', + ]))->assertStatus(202); +}); + it('accepts a monthly and yearly export without a month', function () { $this->loginUser(); Mail::fake(); From b9278354675dca4614033ff1f95ea6eb321d23d6 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 16:00:40 +0200 Subject: [PATCH 09/14] refactor: inject CoreExportProvider instead of a global provider registry The global-provider mechanism on ExportProviderRegistry only existed for the core exports; injecting the provider directly keeps the registry an exact mirror of TicketProviderRegistry. --- app/Integrations/ExportProviderRegistry.php | 18 ------------------ app/Integrations/ExportService.php | 8 ++++++-- app/Providers/AppServiceProvider.php | 3 --- tests/Unit/Integrations/ExportServiceTest.php | 11 ++++++----- 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/app/Integrations/ExportProviderRegistry.php b/app/Integrations/ExportProviderRegistry.php index cd86fb4..eba1e8e 100644 --- a/app/Integrations/ExportProviderRegistry.php +++ b/app/Integrations/ExportProviderRegistry.php @@ -9,21 +9,12 @@ class ExportProviderRegistry /** @var array>> */ private array $classes = []; - /** @var list> */ - private array $globalClasses = []; - /** @param class-string $providerClass */ public function register(string $type, string $providerClass): void { $this->classes[$type][] = $providerClass; } - /** @param class-string $providerClass */ - public function registerGlobal(string $providerClass): void - { - $this->globalClasses[] = $providerClass; - } - /** * @param array $config * @return list @@ -36,15 +27,6 @@ public function makeProviders(string $type, array $config): array ); } - /** @return list */ - public function makeGlobalProviders(): array - { - return array_map( - fn (string $class): ExportProviderInterface => $class::fromConfig([]), - $this->globalClasses, - ); - } - /** @return list */ public function registeredTypes(): array { diff --git a/app/Integrations/ExportService.php b/app/Integrations/ExportService.php index 2c538fe..c8a6c5b 100644 --- a/app/Integrations/ExportService.php +++ b/app/Integrations/ExportService.php @@ -4,6 +4,7 @@ use App\DataTransferObjects\ExportFormat; use App\DataTransferObjects\ExportPeriod; +use App\Exports\CoreExportProvider; use App\Integrations\Contracts\ExportInterface; use App\Integrations\Contracts\ExportProviderInterface; use App\Models\Integration; @@ -13,7 +14,10 @@ class ExportService { - public function __construct(private readonly ExportProviderRegistry $registry) {} + public function __construct( + private readonly ExportProviderRegistry $registry, + private readonly CoreExportProvider $coreProvider, + ) {} /** @return Collection */ public function formats(): Collection @@ -60,7 +64,7 @@ public function createExport(string $key, ExportPeriod $period): ExportInterface /** @return Collection */ private function resolveProviders(): Collection { - return collect($this->registry->makeGlobalProviders()) + return collect([$this->coreProvider]) ->merge( Integration::whereIn('type', $this->registry->registeredTypes())->get() ->flatMap(fn (Integration $integration) => $this->registry->makeProviders( diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 67a08f2..c192226 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,6 @@ namespace App\Providers; -use App\Exports\CoreExportProvider; use App\Integrations\ExportProviderRegistry; use App\Integrations\ExportService; use App\Integrations\IntegrationTypeRegistry; @@ -44,8 +43,6 @@ public function register(): void public function boot(): void { - $this->app->make(ExportProviderRegistry::class)->registerGlobal(CoreExportProvider::class); - $this->app->booted(function () { if (! Schema::hasTable('permissions')) { return; diff --git a/tests/Unit/Integrations/ExportServiceTest.php b/tests/Unit/Integrations/ExportServiceTest.php index 048d06f..2461031 100644 --- a/tests/Unit/Integrations/ExportServiceTest.php +++ b/tests/Unit/Integrations/ExportServiceTest.php @@ -4,18 +4,19 @@ use App\Exports\CoreExportProvider; use App\Integrations\ExportProviderRegistry; use App\Integrations\ExportService; +use App\Models\Integration; it('throws when providers register duplicate format keys', function () { + Integration::create(['name' => 'Duplicate core', 'type' => 'duplicate-core', 'config' => []]); + $registry = new ExportProviderRegistry; - $registry->registerGlobal(CoreExportProvider::class); - $registry->registerGlobal(CoreExportProvider::class); + $registry->register('duplicate-core', CoreExportProvider::class); - (new ExportService($registry))->formats(); + (new ExportService($registry, new CoreExportProvider))->formats(); })->throws(RuntimeException::class, 'Duplicate export format keys'); it('throws when creating an export for an unknown format', function () { $registry = new ExportProviderRegistry; - $registry->registerGlobal(CoreExportProvider::class); - (new ExportService($registry))->createExport('unknown-format', new ExportPeriod(2026, 6)); + (new ExportService($registry, new CoreExportProvider))->createExport('unknown-format', new ExportPeriod(2026, 6)); })->throws(InvalidArgumentException::class, 'Unknown export format: unknown-format'); From f5dd384612c018d1dd0364fca35f939032494593 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 16:03:13 +0200 Subject: [PATCH 10/14] refactor: move year and month requirement logic into ExportPeriodOptions --- app/Enums/ExportPeriodOptions.php | 10 ++++++++++ app/Http/Requests/ExportRequest.php | 5 ++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/Enums/ExportPeriodOptions.php b/app/Enums/ExportPeriodOptions.php index 0552b3b..4fb4e11 100644 --- a/app/Enums/ExportPeriodOptions.php +++ b/app/Enums/ExportPeriodOptions.php @@ -7,4 +7,14 @@ enum ExportPeriodOptions: string case None = 'none'; case Monthly = 'monthly'; case MonthlyAndYearly = 'monthly-and-yearly'; + + public function yearIsRequired(): bool + { + return $this == self::Monthly || $this == self::MonthlyAndYearly; + } + + public function monthIsRequired(): bool + { + return $this === self::Monthly; + } } diff --git a/app/Http/Requests/ExportRequest.php b/app/Http/Requests/ExportRequest.php index a63f004..d1c3dd5 100644 --- a/app/Http/Requests/ExportRequest.php +++ b/app/Http/Requests/ExportRequest.php @@ -2,7 +2,6 @@ namespace App\Http\Requests; -use App\Enums\ExportPeriodOptions; use App\Integrations\ExportService; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; @@ -30,11 +29,11 @@ public function rules(ExportService $exportService): array return [ 'exportType' => ['required', 'string', Rule::in($exportService->formatKeys())], 'year' => [ - Rule::requiredIf($format !== null && $format->periodOptions !== ExportPeriodOptions::None), + Rule::requiredIf($format?->periodOptions->yearIsRequired() ?? false), 'nullable', 'integer', 'min:2000', 'max:'.date('Y'), ], 'month' => [ - Rule::requiredIf($format?->periodOptions === ExportPeriodOptions::Monthly), + Rule::requiredIf($format?->periodOptions->monthIsRequired() ?? false), 'nullable', 'integer', 'min:1', 'max:12', ], ]; From c477efe60fa32f7fc6ccdf6702cdc8e7bdf58959 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 16:08:59 +0200 Subject: [PATCH 11/14] refactor: use english identifiers for the exact ledger mapping Verbruik and Vrijval remain only in Filament labels and in the CSV content Exact expects; code and config keys use usage/release. --- .../exact-globe/src/BudgetsExactExport.php | 12 +++++--- .../src/DataTransferObjects/LedgerMapping.php | 16 +++++----- .../src/Filament/Pages/LedgerMappingPage.php | 16 +++++----- .../tests/BudgetsExactExportTest.php | 30 +++++++++---------- tests/Feature/Exports/ExportFormatsTest.php | 8 ++--- 5 files changed, 43 insertions(+), 39 deletions(-) diff --git a/integrations/exact-globe/src/BudgetsExactExport.php b/integrations/exact-globe/src/BudgetsExactExport.php index 3930a50..710114b 100644 --- a/integrations/exact-globe/src/BudgetsExactExport.php +++ b/integrations/exact-globe/src/BudgetsExactExport.php @@ -15,6 +15,10 @@ class BudgetsExactExport implements ExportInterface { + private const USAGE_DESCRIPTION = 'Verbruik'; + + private const RELEASE_DESCRIPTION = 'Vrijval'; + private Carbon $month; /** @@ -159,11 +163,11 @@ public function rows(): Collection ->filter(fn (BudgetMutation $usage) => $this->ledgerMappings->has($usage->budget->budget_type_id)); foreach ($budgetUsage as $budgetMutation) { - $this->addRowPairToCollection('Verbruik', $budgetMutation->usedCredit, $budgetMutation, $rows); + $this->addRowPairToCollection(self::USAGE_DESCRIPTION, $budgetMutation->usedCredit, $budgetMutation, $rows); } foreach ($budgetUsage as $budgetMutation) { - $this->addRowPairToCollection('Vrijval', $budgetMutation->expiredCredit, $budgetMutation, $rows); + $this->addRowPairToCollection(self::RELEASE_DESCRIPTION, $budgetMutation->expiredCredit, $budgetMutation, $rows); } return $rows; @@ -175,8 +179,8 @@ private function ledgerId(MutationRow $row): string $mapping = $this->ledgerMappings->get($row->budgetTypeId); return match ($row->description) { - 'Verbruik' => $row->credit ? $mapping->verbruikCreditLedgerId : $mapping->verbruikDebitLedgerId, - default => $row->credit ? $mapping->vrijvalCreditLedgerId : $mapping->vrijvalDebitLedgerId, + self::USAGE_DESCRIPTION => $row->credit ? $mapping->usageCreditLedgerId : $mapping->usageDebitLedgerId, + default => $row->credit ? $mapping->releaseCreditLedgerId : $mapping->releaseDebitLedgerId, }; } diff --git a/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php b/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php index 809167f..245f64d 100644 --- a/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php +++ b/integrations/exact-globe/src/DataTransferObjects/LedgerMapping.php @@ -6,10 +6,10 @@ { public function __construct( public string $budgetTypeId, - public string $verbruikCreditLedgerId, - public string $verbruikDebitLedgerId, - public string $vrijvalCreditLedgerId, - public string $vrijvalDebitLedgerId, + public string $usageCreditLedgerId, + public string $usageDebitLedgerId, + public string $releaseCreditLedgerId, + public string $releaseDebitLedgerId, ) {} /** @param array $row */ @@ -17,10 +17,10 @@ public static function fromConfigRow(string $budgetTypeId, array $row): self { return new self( budgetTypeId: $budgetTypeId, - verbruikCreditLedgerId: (string) ($row['verbruik_credit'] ?? ''), - verbruikDebitLedgerId: (string) ($row['verbruik_debit'] ?? ''), - vrijvalCreditLedgerId: (string) ($row['vrijval_credit'] ?? ''), - vrijvalDebitLedgerId: (string) ($row['vrijval_debit'] ?? ''), + usageCreditLedgerId: (string) ($row['usage_credit'] ?? ''), + usageDebitLedgerId: (string) ($row['usage_debit'] ?? ''), + releaseCreditLedgerId: (string) ($row['release_credit'] ?? ''), + releaseDebitLedgerId: (string) ($row['release_debit'] ?? ''), ); } } diff --git a/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php index 8020b13..34ef719 100644 --- a/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php +++ b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php @@ -117,16 +117,16 @@ private function budgetTypeSections(): array ->description('Ledger accounts for '.$budgetType->title.' budgets. Leave empty to exclude this budget type from the export.') ->columns(2) ->schema([ - TextInput::make("ledger_mapping.{$budgetType->id}.verbruik_credit") + TextInput::make("ledger_mapping.{$budgetType->id}.usage_credit") ->label('Verbruik credit ledger') ->numeric(), - TextInput::make("ledger_mapping.{$budgetType->id}.verbruik_debit") + TextInput::make("ledger_mapping.{$budgetType->id}.usage_debit") ->label('Verbruik debit ledger') ->numeric(), - TextInput::make("ledger_mapping.{$budgetType->id}.vrijval_credit") + TextInput::make("ledger_mapping.{$budgetType->id}.release_credit") ->label('Vrijval credit ledger') ->numeric(), - TextInput::make("ledger_mapping.{$budgetType->id}.vrijval_debit") + TextInput::make("ledger_mapping.{$budgetType->id}.release_debit") ->label('Vrijval debit ledger') ->numeric(), ])) @@ -141,10 +141,10 @@ private function completedMappingRows(array $rows): array { return array_filter( $rows, - fn (array $row): bool => filled($row['verbruik_credit'] ?? null) - && filled($row['verbruik_debit'] ?? null) - && filled($row['vrijval_credit'] ?? null) - && filled($row['vrijval_debit'] ?? null), + fn (array $row): bool => filled($row['usage_credit'] ?? null) + && filled($row['usage_debit'] ?? null) + && filled($row['release_credit'] ?? null) + && filled($row['release_debit'] ?? null), ); } diff --git a/integrations/exact-globe/tests/BudgetsExactExportTest.php b/integrations/exact-globe/tests/BudgetsExactExportTest.php index 4fb8bf5..790ed71 100644 --- a/integrations/exact-globe/tests/BudgetsExactExportTest.php +++ b/integrations/exact-globe/tests/BudgetsExactExportTest.php @@ -16,7 +16,7 @@ uses(RefreshDatabase::class); -it('writes a credit and debit row per verbruik and vrijval mutation with the mapped ledger ids', function () { +it('writes a credit and debit row per usage and release mutation with the mapped ledger ids', function () { $budget = Budget::factory() ->for(Customer::factory()->state(['external_id' => 'CUST-42'])) ->create(['budget_type_id' => 'project']); @@ -38,16 +38,16 @@ $rows = array_map('str_getcsv', file($filePath)); expect($rows)->toHaveCount(5); - [$verbruikCredit, $verbruikDebit, $vrijvalCredit, $vrijvalDebit] = array_slice($rows, 1); - expect($verbruikCredit[8])->toBe('1001') - ->and($verbruikCredit[12])->toBe('+10,5') - ->and($verbruikCredit[9])->toBe('CUST-42') - ->and($verbruikDebit[8])->toBe('1002') - ->and($verbruikDebit[12])->toBe('-10,5') - ->and($vrijvalCredit[8])->toBe('2001') - ->and($vrijvalCredit[12])->toBe('+2') - ->and($vrijvalDebit[8])->toBe('2002') - ->and($vrijvalDebit[12])->toBe('-2'); + [$usageCredit, $usageDebit, $releaseCredit, $releaseDebit] = array_slice($rows, 1); + expect($usageCredit[8])->toBe('1001') + ->and($usageCredit[12])->toBe('+10,5') + ->and($usageCredit[9])->toBe('CUST-42') + ->and($usageDebit[8])->toBe('1002') + ->and($usageDebit[12])->toBe('-10,5') + ->and($releaseCredit[8])->toBe('2001') + ->and($releaseCredit[12])->toBe('+2') + ->and($releaseDebit[8])->toBe('2002') + ->and($releaseDebit[12])->toBe('-2'); }); it('skips budgets whose type has no ledger mapping', function () { @@ -73,10 +73,10 @@ $provider = ExactGlobeExportProvider::fromConfig([ 'ledger_mapping' => [ 'project' => [ - 'verbruik_credit' => '1001', - 'verbruik_debit' => '1002', - 'vrijval_credit' => '2001', - 'vrijval_debit' => '2002', + 'usage_credit' => '1001', + 'usage_debit' => '1002', + 'release_credit' => '2001', + 'release_debit' => '2002', ], ], ]); diff --git a/tests/Feature/Exports/ExportFormatsTest.php b/tests/Feature/Exports/ExportFormatsTest.php index cde575c..aa01716 100644 --- a/tests/Feature/Exports/ExportFormatsTest.php +++ b/tests/Feature/Exports/ExportFormatsTest.php @@ -36,10 +36,10 @@ 'config' => [ 'ledger_mapping' => [ 'project' => [ - 'verbruik_credit' => '28075', - 'verbruik_debit' => '81135', - 'vrijval_credit' => '28075', - 'vrijval_debit' => '81131', + 'usage_credit' => '28075', + 'usage_debit' => '81135', + 'release_credit' => '28075', + 'release_debit' => '81131', ], ], ], From 108877782b22facc6165ebec1c408330426d7325 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 16:21:13 +0200 Subject: [PATCH 12/14] fix: don't link integrations whose type has no registered page The table recordUrl fell back to IntegrationResource::getUrl('edit'), but no edit page is registered, so listing an integration with an unregistered type threw Route [filament.admin.resources.integrations.edit] not defined. Return null instead so such rows are simply not clickable. Claude --- .../Resources/Integrations/Tables/IntegrationsTable.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php b/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php index a0a4610..6fcb632 100644 --- a/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php +++ b/app/Filament/Resources/Integrations/Tables/IntegrationsTable.php @@ -2,7 +2,6 @@ namespace App\Filament\Resources\Integrations\Tables; -use App\Filament\Resources\Integrations\IntegrationResource; use App\Integrations\IntegrationTypeRegistry; use App\Models\Integration; use Filament\Tables\Columns\TextColumn; @@ -23,7 +22,7 @@ public static function configure(Table $table): Table return $pageClass ? $pageClass::getUrl(['record' => $record->id]) - : IntegrationResource::getUrl('edit', ['record' => $record]); + : null; }); } } From bb8538c7ac1ecaec90bd68039ab0253e2056ad19 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 16:41:37 +0200 Subject: [PATCH 13/14] enable ledgers for budget types --- .../src/Filament/Pages/LedgerMappingPage.php | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php index 34ef719..5c54d86 100644 --- a/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php +++ b/integrations/exact-globe/src/Filament/Pages/LedgerMappingPage.php @@ -7,12 +7,14 @@ use App\Models\Integration; use Filament\Actions\Action; use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; use Filament\Navigation\NavigationItem; use Filament\Notifications\Notification; use Filament\Pages\Enums\SubNavigationPosition; use Filament\Resources\Pages\Concerns\InteractsWithRecord; use Filament\Resources\Pages\Page; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; /** @@ -55,10 +57,12 @@ public function mount(int|string $record): void $this->record = $this->resolveRecord($record); $config = $this->getIntegration()->config ?? []; + $ledgerMapping = $config['ledger_mapping'] ?? []; $this->form->fill([ 'name' => $this->getIntegration()->name, - 'ledger_mapping' => $config['ledger_mapping'] ?? [], + 'ledger_mapping' => $ledgerMapping, + 'enabled' => array_fill_keys(array_keys($ledgerMapping), true), ]); } @@ -91,7 +95,10 @@ protected function getHeaderActions(): array $data = $this->form->getState(); $config = array_merge($this->getIntegration()->config ?? [], [ - 'ledger_mapping' => $this->completedMappingRows($data['ledger_mapping'] ?? []), + 'ledger_mapping' => $this->completedMappingRows( + $data['ledger_mapping'] ?? [], + $data['enabled'] ?? [], + ), ]); $this->getIntegration()->update([ @@ -114,37 +121,49 @@ private function budgetTypeSections(): array ->orderBy('title') ->get() ->map(fn (BudgetType $budgetType): Section => Section::make($budgetType->title) - ->description('Ledger accounts for '.$budgetType->title.' budgets. Leave empty to exclude this budget type from the export.') + ->description('Enable to map ledger accounts for '.$budgetType->title.' budgets. Disabled budget types are excluded from the export.') ->columns(2) ->schema([ + Toggle::make("enabled.{$budgetType->id}") + ->label('Include in export') + ->live() + ->columnSpanFull(), + TextInput::make("ledger_mapping.{$budgetType->id}.usage_credit") ->label('Verbruik credit ledger') - ->numeric(), + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), TextInput::make("ledger_mapping.{$budgetType->id}.usage_debit") ->label('Verbruik debit ledger') - ->numeric(), + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), TextInput::make("ledger_mapping.{$budgetType->id}.release_credit") ->label('Vrijval credit ledger') - ->numeric(), + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), TextInput::make("ledger_mapping.{$budgetType->id}.release_debit") ->label('Vrijval debit ledger') - ->numeric(), + ->numeric() + ->visible(fn (Get $get): bool => (bool) $get("enabled.{$budgetType->id}")), ])) ->all()); } /** * @param array> $rows + * @param array $enabled * @return array> */ - private function completedMappingRows(array $rows): array + private function completedMappingRows(array $rows, array $enabled): array { return array_filter( $rows, - fn (array $row): bool => filled($row['usage_credit'] ?? null) + fn (array $row, string $budgetTypeId): bool => ($enabled[$budgetTypeId] ?? false) + && filled($row['usage_credit'] ?? null) && filled($row['usage_debit'] ?? null) && filled($row['release_credit'] ?? null) && filled($row['release_debit'] ?? null), + ARRAY_FILTER_USE_BOTH, ); } From 7c5c169111e97cda0aa11ac7c83493db9c5bc5b9 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 15 Jul 2026 16:44:12 +0200 Subject: [PATCH 14/14] phpstan fix --- integrations/rework/src/Connector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/rework/src/Connector.php b/integrations/rework/src/Connector.php index 481a7ff..d2fc1b2 100644 --- a/integrations/rework/src/Connector.php +++ b/integrations/rework/src/Connector.php @@ -24,7 +24,7 @@ public function resolveBaseUrl(): string protected function defaultAuth(): HeaderAuthenticator { - return new HeaderAuthenticator((string) $this->apiKey, 'Authorization', 'Token '); + return new HeaderAuthenticator((string) $this->apiKey, 'Authorization'); } protected function defaultHeaders(): array