From 978b5fb0c2385cc9d91d714e09da531dcfc5a808 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:21:31 +0300 Subject: [PATCH 01/22] CONV-215: Create CreditCost DTO Co-Authored-By: Claude Sonnet 4.6 --- app/Data/Credits/CreditCost.php | 17 +++++++++++++++++ tests/Unit/Data/CreditCostTest.php | 30 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 app/Data/Credits/CreditCost.php create mode 100644 tests/Unit/Data/CreditCostTest.php diff --git a/app/Data/Credits/CreditCost.php b/app/Data/Credits/CreditCost.php new file mode 100644 index 0000000..f64369d --- /dev/null +++ b/app/Data/Credits/CreditCost.php @@ -0,0 +1,17 @@ +amount < 0) { + throw new \InvalidArgumentException('Credit cost amount cannot be negative.'); + } + } +} diff --git a/tests/Unit/Data/CreditCostTest.php b/tests/Unit/Data/CreditCostTest.php new file mode 100644 index 0000000..541baae --- /dev/null +++ b/tests/Unit/Data/CreditCostTest.php @@ -0,0 +1,30 @@ +amount)->toBe(2); + expect($cost->breakdown)->toBe([]); +}); + +it('creates credit cost with breakdown data', function () { + $breakdown = ['base' => 2, 'total' => 2]; + $cost = new CreditCost(amount: 2, breakdown: $breakdown); + + expect($cost->amount)->toBe(2); + expect($cost->breakdown)->toBe($breakdown); +}); + +it('rejects negative amount', function () { + new CreditCost(amount: -1, breakdown: []); +})->throws(InvalidArgumentException::class); + +it('allows zero amount', function () { + $cost = new CreditCost(amount: 0, breakdown: []); + + expect($cost->amount)->toBe(0); +}); From b41f371971a2842fffdfd1ec53c79da524d0a3ee Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:22:30 +0300 Subject: [PATCH 02/22] CONV-216: Create CreditCostBreakdown DTO Co-Authored-By: Claude Sonnet 4.6 --- app/Data/Credits/CreditCostBreakdown.php | 40 +++++++++++++ tests/Unit/Data/CreditCostBreakdownTest.php | 65 +++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 app/Data/Credits/CreditCostBreakdown.php create mode 100644 tests/Unit/Data/CreditCostBreakdownTest.php diff --git a/app/Data/Credits/CreditCostBreakdown.php b/app/Data/Credits/CreditCostBreakdown.php new file mode 100644 index 0000000..2ac9dff --- /dev/null +++ b/app/Data/Credits/CreditCostBreakdown.php @@ -0,0 +1,40 @@ +base < 0) { + throw new \InvalidArgumentException('Breakdown base cannot be negative.'); + } + if ($this->size < 0) { + throw new \InvalidArgumentException('Breakdown size cannot be negative.'); + } + if ($this->features < 0) { + throw new \InvalidArgumentException('Breakdown features cannot be negative.'); + } + if ($this->total < 0) { + throw new \InvalidArgumentException('Breakdown total cannot be negative.'); + } + } + + public function toArray(): array + { + return [ + 'base' => $this->base, + 'size' => $this->size, + 'features' => $this->features, + 'total' => $this->total, + 'details' => $this->details, + ]; + } +} diff --git a/tests/Unit/Data/CreditCostBreakdownTest.php b/tests/Unit/Data/CreditCostBreakdownTest.php new file mode 100644 index 0000000..1c0f145 --- /dev/null +++ b/tests/Unit/Data/CreditCostBreakdownTest.php @@ -0,0 +1,65 @@ + 'pdf'], + ); + + expect($breakdown->total)->toBe(2); + expect($breakdown->toArray()['base'])->toBe(1); +}); + +it('exposes all fields', function () { + $breakdown = new CreditCostBreakdown( + base: 2, + size: 0, + features: 0, + total: 2, + details: ['rule' => 'image_to_pdf'], + ); + + expect($breakdown->base)->toBe(2); + expect($breakdown->size)->toBe(0); + expect($breakdown->features)->toBe(0); + expect($breakdown->total)->toBe(2); + expect($breakdown->details)->toBe(['rule' => 'image_to_pdf']); +}); + +it('converts to array with all keys', function () { + $breakdown = new CreditCostBreakdown( + base: 1, + size: 0, + features: 0, + total: 1, + details: [], + ); + + $array = $breakdown->toArray(); + + expect($array)->toHaveKeys(['base', 'size', 'features', 'total', 'details']); + expect($array['total'])->toBe(1); +}); + +it('rejects negative base', function () { + new CreditCostBreakdown(base: -1, size: 0, features: 0, total: 0, details: []); +})->throws(InvalidArgumentException::class); + +it('rejects negative size', function () { + new CreditCostBreakdown(base: 0, size: -1, features: 0, total: 0, details: []); +})->throws(InvalidArgumentException::class); + +it('rejects negative features', function () { + new CreditCostBreakdown(base: 0, size: 0, features: -1, total: 0, details: []); +})->throws(InvalidArgumentException::class); + +it('rejects negative total', function () { + new CreditCostBreakdown(base: 0, size: 0, features: 0, total: -1, details: []); +})->throws(InvalidArgumentException::class); From 0f6bfea1675c1d58858bf837548fc9e320f0c780 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:23:06 +0300 Subject: [PATCH 03/22] CONV-217: Create ConversionCostEstimator contract Co-Authored-By: Claude Sonnet 4.6 --- .../Billing/ConversionCostEstimator.php | 17 +++++++++++++++++ .../ConversionCostEstimatorContractTest.php | 9 +++++++++ 2 files changed, 26 insertions(+) create mode 100644 app/Contracts/Billing/ConversionCostEstimator.php create mode 100644 tests/Unit/Contracts/ConversionCostEstimatorContractTest.php diff --git a/app/Contracts/Billing/ConversionCostEstimator.php b/app/Contracts/Billing/ConversionCostEstimator.php new file mode 100644 index 0000000..d959b72 --- /dev/null +++ b/app/Contracts/Billing/ConversionCostEstimator.php @@ -0,0 +1,17 @@ + $options + */ + public function estimate(FileRecord $file, Converter $converter, array $options = []): CreditCost; +} diff --git a/tests/Unit/Contracts/ConversionCostEstimatorContractTest.php b/tests/Unit/Contracts/ConversionCostEstimatorContractTest.php new file mode 100644 index 0000000..9bb8b5f --- /dev/null +++ b/tests/Unit/Contracts/ConversionCostEstimatorContractTest.php @@ -0,0 +1,9 @@ +toBeTrue(); +}); From b623a9556abf92cd59a1885e6f34b3e2f1327aab Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:23:56 +0300 Subject: [PATCH 04/22] CONV-218: Create ConfigDrivenConversionCostEstimator skeleton Co-Authored-By: Claude Sonnet 4.6 --- app/Providers/AppServiceProvider.php | 3 +++ .../ConfigDrivenConversionCostEstimator.php | 21 +++++++++++++++++++ .../ConversionCostEstimatorResolutionTest.php | 12 +++++++++++ 3 files changed, 36 insertions(+) create mode 100644 app/Services/Billing/ConfigDrivenConversionCostEstimator.php create mode 100644 tests/Unit/Services/ConversionCostEstimatorResolutionTest.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 985c259..8960068 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,11 @@ namespace App\Providers; +use App\Contracts\Billing\ConversionCostEstimator; use App\Contracts\Billing\CreditLedger; use App\Models\User; use App\Observers\UserObserver; +use App\Services\Billing\ConfigDrivenConversionCostEstimator; use App\Services\Billing\DatabaseCreditLedger; use Illuminate\Support\ServiceProvider; @@ -16,6 +18,7 @@ class AppServiceProvider extends ServiceProvider public function register(): void { $this->app->bind(CreditLedger::class, DatabaseCreditLedger::class); + $this->app->bind(ConversionCostEstimator::class, ConfigDrivenConversionCostEstimator::class); } /** diff --git a/app/Services/Billing/ConfigDrivenConversionCostEstimator.php b/app/Services/Billing/ConfigDrivenConversionCostEstimator.php new file mode 100644 index 0000000..e591829 --- /dev/null +++ b/app/Services/Billing/ConfigDrivenConversionCostEstimator.php @@ -0,0 +1,21 @@ + $options + */ + public function estimate(FileRecord $file, Converter $converter, array $options = []): CreditCost + { + throw new \LogicException('Not implemented yet.'); + } +} diff --git a/tests/Unit/Services/ConversionCostEstimatorResolutionTest.php b/tests/Unit/Services/ConversionCostEstimatorResolutionTest.php new file mode 100644 index 0000000..d8aeeb7 --- /dev/null +++ b/tests/Unit/Services/ConversionCostEstimatorResolutionTest.php @@ -0,0 +1,12 @@ +toBeInstanceOf(ConfigDrivenConversionCostEstimator::class); +}); From 70ce0bd2fbdbdc69035619ea20c89a05bec9330b Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:24:34 +0300 Subject: [PATCH 05/22] CONV-219: Add conversion costs config Co-Authored-By: Claude Sonnet 4.6 --- config/conversion_costs.php | 23 +++++++++++++++++++ .../Unit/Config/ConversionCostsConfigTest.php | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 config/conversion_costs.php create mode 100644 tests/Unit/Config/ConversionCostsConfigTest.php diff --git a/config/conversion_costs.php b/config/conversion_costs.php new file mode 100644 index 0000000..436c29c --- /dev/null +++ b/config/conversion_costs.php @@ -0,0 +1,23 @@ + [ + 'image_to_image' => [ + 'base' => 1, + 'size' => 0, + 'features' => [], + ], + 'image_to_pdf' => [ + 'base' => 2, + 'size' => 0, + 'features' => [], + ], + ], + + 'groups' => [ + 'image' => ['png', 'jpg', 'jpeg', 'webp'], + 'pdf' => ['pdf'], + ], +]; diff --git a/tests/Unit/Config/ConversionCostsConfigTest.php b/tests/Unit/Config/ConversionCostsConfigTest.php new file mode 100644 index 0000000..a4bf9d6 --- /dev/null +++ b/tests/Unit/Config/ConversionCostsConfigTest.php @@ -0,0 +1,23 @@ +toBe(1); + expect(config('conversion_costs.rules.image_to_pdf.base'))->toBe(2); +}); + +it('has image format group', function () { + $imageFormats = config('conversion_costs.groups.image'); + + expect($imageFormats)->toContain('png'); + expect($imageFormats)->toContain('jpg'); + expect($imageFormats)->toContain('jpeg'); + expect($imageFormats)->toContain('webp'); +}); + +it('has pdf format group', function () { + $pdfFormats = config('conversion_costs.groups.pdf'); + + expect($pdfFormats)->toContain('pdf'); +}); From e737081f814a8091a2038d95b83db69aa3f31057 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:25:38 +0300 Subject: [PATCH 06/22] CONV-220: Test image to image cost Co-Authored-By: Claude Sonnet 4.6 --- database/factories/FileRecordFactory.php | 20 +++++++++++++++ .../Services/ConversionCostEstimatorTest.php | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/Unit/Services/ConversionCostEstimatorTest.php diff --git a/database/factories/FileRecordFactory.php b/database/factories/FileRecordFactory.php index 49960ac..d5a9dd3 100644 --- a/database/factories/FileRecordFactory.php +++ b/database/factories/FileRecordFactory.php @@ -19,6 +19,26 @@ public function expired(): static return $this->state(['expires_at' => now()->subHour()]); } + public function png(): static + { + return $this->state([ + 'original_name' => fake()->slug().'.png', + 'stored_path' => 'uploads/'.fake()->uuid().'.png', + 'mime_type' => 'image/png', + 'extension' => 'png', + ]); + } + + public function jpg(): static + { + return $this->state([ + 'original_name' => fake()->slug().'.jpg', + 'stored_path' => 'uploads/'.fake()->uuid().'.jpg', + 'mime_type' => 'image/jpeg', + 'extension' => 'jpg', + ]); + } + public function definition(): array { $extension = fake()->randomElement(['png', 'jpg', 'webp', 'pdf']); diff --git a/tests/Unit/Services/ConversionCostEstimatorTest.php b/tests/Unit/Services/ConversionCostEstimatorTest.php new file mode 100644 index 0000000..5954fe6 --- /dev/null +++ b/tests/Unit/Services/ConversionCostEstimatorTest.php @@ -0,0 +1,25 @@ +png()->make(['user_id' => 1]); + $converter = app(ConverterRegistry::class)->find('png', 'jpg'); + + $cost = app(ConversionCostEstimator::class)->estimate($file, $converter, []); + + expect($cost->amount)->toBe(1); +}); + +it('estimates jpg to webp conversion as one credit', function () { + $file = FileRecord::factory()->jpg()->make(['user_id' => 1]); + $converter = app(ConverterRegistry::class)->find('jpg', 'webp'); + + $cost = app(ConversionCostEstimator::class)->estimate($file, $converter, []); + + expect($cost->amount)->toBe(1); +}); From 9ea385eca2bcabdfc16ef80d6528b6defcc96264 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:26:23 +0300 Subject: [PATCH 07/22] CONV-221: Implement image to image cost Co-Authored-By: Claude Sonnet 4.6 --- .../UnsupportedConversionCostException.php | 15 ++++++ .../ConfigDrivenConversionCostEstimator.php | 46 ++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 app/Exceptions/Billing/UnsupportedConversionCostException.php diff --git a/app/Exceptions/Billing/UnsupportedConversionCostException.php b/app/Exceptions/Billing/UnsupportedConversionCostException.php new file mode 100644 index 0000000..5964e93 --- /dev/null +++ b/app/Exceptions/Billing/UnsupportedConversionCostException.php @@ -0,0 +1,15 @@ +sourceFormat(); + $target = $converter->targetFormat(); + + if ($this->isImage($source) && $this->isImage($target)) { + return $this->makeCost('image_to_image', $file, $converter); + } + + if ($this->isImage($source) && $this->isPdf($target)) { + return $this->makeCost('image_to_pdf', $file, $converter); + } + + throw UnsupportedConversionCostException::forPair($source, $target); + } + + private function makeCost(string $rule, FileRecord $file, Converter $converter): CreditCost + { + $base = (int) config("conversion_costs.rules.{$rule}.base", 1); + + return new CreditCost( + amount: $base, + breakdown: [ + 'base' => $base, + 'size' => 0, + 'features' => 0, + 'total' => $base, + 'details' => [ + 'rule' => $rule, + 'source_format' => $converter->sourceFormat(), + 'target_format' => $converter->targetFormat(), + 'converter_key' => $converter->key(), + 'file_size_bytes' => $file->size_bytes, + ], + ], + ); + } + + private function isImage(string $format): bool + { + return in_array($format, config('conversion_costs.groups.image', []), true); + } + + private function isPdf(string $format): bool + { + return in_array($format, config('conversion_costs.groups.pdf', []), true); } } From 20d2b6278bf93523e2ef11606f7e842706439968 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:26:58 +0300 Subject: [PATCH 08/22] CONV-222: Test image to PDF cost Co-Authored-By: Claude Sonnet 4.6 --- .../Services/ConversionCostEstimatorTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Unit/Services/ConversionCostEstimatorTest.php b/tests/Unit/Services/ConversionCostEstimatorTest.php index 5954fe6..24329a2 100644 --- a/tests/Unit/Services/ConversionCostEstimatorTest.php +++ b/tests/Unit/Services/ConversionCostEstimatorTest.php @@ -23,3 +23,21 @@ expect($cost->amount)->toBe(1); }); + +it('estimates image to pdf conversion as two credits', function () { + $file = FileRecord::factory()->png()->make(['user_id' => 1]); + $converter = app(ConverterRegistry::class)->find('png', 'pdf'); + + $cost = app(ConversionCostEstimator::class)->estimate($file, $converter, []); + + expect($cost->amount)->toBe(2); +}); + +it('estimates jpg to pdf conversion as two credits', function () { + $file = FileRecord::factory()->jpg()->make(['user_id' => 1]); + $converter = app(ConverterRegistry::class)->find('jpg', 'pdf'); + + $cost = app(ConversionCostEstimator::class)->estimate($file, $converter, []); + + expect($cost->amount)->toBe(2); +}); From 3c2c0f8be7da4f64eeb2ea3006ba5c9cdb322018 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:27:35 +0300 Subject: [PATCH 09/22] CONV-223: Implement image to PDF cost Co-Authored-By: Claude Sonnet 4.6 --- tests/Unit/Services/ConversionCostEstimatorTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Unit/Services/ConversionCostEstimatorTest.php b/tests/Unit/Services/ConversionCostEstimatorTest.php index 24329a2..4315947 100644 --- a/tests/Unit/Services/ConversionCostEstimatorTest.php +++ b/tests/Unit/Services/ConversionCostEstimatorTest.php @@ -41,3 +41,12 @@ expect($cost->amount)->toBe(2); }); + +it('estimates png to webp conversion as one credit', function () { + $file = FileRecord::factory()->png()->make(['user_id' => 1]); + $converter = app(ConverterRegistry::class)->find('png', 'webp'); + + $cost = app(ConversionCostEstimator::class)->estimate($file, $converter, []); + + expect($cost->amount)->toBe(1); +}); From f9d9adda1e92bd5dd9cd79d6b4fa94bd3d03c549 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:28:08 +0300 Subject: [PATCH 10/22] CONV-224: Test cost breakdown shape Co-Authored-By: Claude Sonnet 4.6 --- .../Services/ConversionCostEstimatorTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Unit/Services/ConversionCostEstimatorTest.php b/tests/Unit/Services/ConversionCostEstimatorTest.php index 4315947..fc0a7af 100644 --- a/tests/Unit/Services/ConversionCostEstimatorTest.php +++ b/tests/Unit/Services/ConversionCostEstimatorTest.php @@ -50,3 +50,20 @@ expect($cost->amount)->toBe(1); }); + +it('returns stable cost breakdown', function () { + $file = FileRecord::factory()->png()->make(['user_id' => 1]); + $converter = app(ConverterRegistry::class)->find('png', 'pdf'); + + $cost = app(ConversionCostEstimator::class)->estimate($file, $converter, []); + + expect($cost->breakdown)->toHaveKeys([ + 'base', + 'size', + 'features', + 'total', + 'details', + ]); + + expect($cost->breakdown['total'])->toBe($cost->amount); +}); From 2f5152848d176d2467b150ffe10bf44bd02cce44 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:28:46 +0300 Subject: [PATCH 11/22] CONV-225: Implement cost breakdown Co-Authored-By: Claude Sonnet 4.6 --- .../ConfigDrivenConversionCostEstimator.php | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/app/Services/Billing/ConfigDrivenConversionCostEstimator.php b/app/Services/Billing/ConfigDrivenConversionCostEstimator.php index 3cc8634..64f6a47 100644 --- a/app/Services/Billing/ConfigDrivenConversionCostEstimator.php +++ b/app/Services/Billing/ConfigDrivenConversionCostEstimator.php @@ -6,6 +6,7 @@ use App\Contracts\Billing\ConversionCostEstimator; use App\Data\Credits\CreditCost; +use App\Data\Credits\CreditCostBreakdown; use App\Exceptions\Billing\UnsupportedConversionCostException; use App\Models\FileRecord; use App\Support\Converters\Contracts\Converter; @@ -35,22 +36,24 @@ private function makeCost(string $rule, FileRecord $file, Converter $converter): { $base = (int) config("conversion_costs.rules.{$rule}.base", 1); - return new CreditCost( - amount: $base, - breakdown: [ - 'base' => $base, - 'size' => 0, - 'features' => 0, - 'total' => $base, - 'details' => [ - 'rule' => $rule, - 'source_format' => $converter->sourceFormat(), - 'target_format' => $converter->targetFormat(), - 'converter_key' => $converter->key(), - 'file_size_bytes' => $file->size_bytes, - ], + $breakdown = new CreditCostBreakdown( + base: $base, + size: 0, + features: 0, + total: $base, + details: [ + 'rule' => $rule, + 'source_format' => $converter->sourceFormat(), + 'target_format' => $converter->targetFormat(), + 'converter_key' => $converter->key(), + 'file_size_bytes' => $file->size_bytes, ], ); + + return new CreditCost( + amount: $breakdown->total, + breakdown: $breakdown->toArray(), + ); } private function isImage(string $format): bool From f877c5b7dc640c70899138bbfb65cbea685cfa44 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:29:41 +0300 Subject: [PATCH 12/22] CONV-226: Test unsupported cost estimation is rejected Co-Authored-By: Claude Sonnet 4.6 --- .../Converters/FakeUnsupportedConverter.php | 53 +++++++++++++++++++ .../Services/ConversionCostEstimatorTest.php | 9 ++++ 2 files changed, 62 insertions(+) create mode 100644 tests/Fakes/Converters/FakeUnsupportedConverter.php diff --git a/tests/Fakes/Converters/FakeUnsupportedConverter.php b/tests/Fakes/Converters/FakeUnsupportedConverter.php new file mode 100644 index 0000000..d080998 --- /dev/null +++ b/tests/Fakes/Converters/FakeUnsupportedConverter.php @@ -0,0 +1,53 @@ +source}_to_{$this->target}"; + } + + public function sourceFormat(): string + { + return $this->source; + } + + public function targetFormat(): string + { + return $this->target; + } + + public function label(): string + { + return strtoupper($this->target); + } + + public function description(): string + { + return 'Unsupported conversion'; + } + + public function optionsSchema(): array + { + return []; + } + + public function validateOptions(array $options): array + { + return $options; + } + + public function isRecommended(): bool + { + return false; + } +} diff --git a/tests/Unit/Services/ConversionCostEstimatorTest.php b/tests/Unit/Services/ConversionCostEstimatorTest.php index fc0a7af..c75fff3 100644 --- a/tests/Unit/Services/ConversionCostEstimatorTest.php +++ b/tests/Unit/Services/ConversionCostEstimatorTest.php @@ -3,8 +3,10 @@ declare(strict_types=1); use App\Contracts\Billing\ConversionCostEstimator; +use App\Exceptions\Billing\UnsupportedConversionCostException; use App\Models\FileRecord; use App\Support\Converters\ConverterRegistry; +use Tests\Fakes\Converters\FakeUnsupportedConverter; it('estimates image to image conversion as one credit', function () { $file = FileRecord::factory()->png()->make(['user_id' => 1]); @@ -51,6 +53,13 @@ expect($cost->amount)->toBe(1); }); +it('rejects unsupported cost estimation', function () { + $file = FileRecord::factory()->png()->make(['user_id' => 1]); + $converter = new FakeUnsupportedConverter('png', 'mp3'); + + app(ConversionCostEstimator::class)->estimate($file, $converter, []); +})->throws(UnsupportedConversionCostException::class); + it('returns stable cost breakdown', function () { $file = FileRecord::factory()->png()->make(['user_id' => 1]); $converter = app(ConverterRegistry::class)->find('png', 'pdf'); From facc74e0d090cfa56b2be7b4181f419cc32af21c Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:30:33 +0300 Subject: [PATCH 13/22] CONV-227: Create EstimateConversionCostAction Co-Authored-By: Claude Sonnet 4.6 --- .../EstimateConversionCostAction.php | 25 +++++++++++++++++++ .../EstimateConversionCostActionTest.php | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 app/Actions/Conversions/EstimateConversionCostAction.php create mode 100644 tests/Feature/Actions/EstimateConversionCostActionTest.php diff --git a/app/Actions/Conversions/EstimateConversionCostAction.php b/app/Actions/Conversions/EstimateConversionCostAction.php new file mode 100644 index 0000000..3d5da39 --- /dev/null +++ b/app/Actions/Conversions/EstimateConversionCostAction.php @@ -0,0 +1,25 @@ + $options + */ + public function handle(FileRecord $file, Converter $converter, array $options = []): CreditCost + { + return $this->estimator->estimate($file, $converter, $options); + } +} diff --git a/tests/Feature/Actions/EstimateConversionCostActionTest.php b/tests/Feature/Actions/EstimateConversionCostActionTest.php new file mode 100644 index 0000000..86fa436 --- /dev/null +++ b/tests/Feature/Actions/EstimateConversionCostActionTest.php @@ -0,0 +1,25 @@ +png()->create(); + $converter = app(ConverterRegistry::class)->find('png', 'jpg'); + + $cost = app(EstimateConversionCostAction::class)->handle($file, $converter, []); + + expect($cost->amount)->toBe(1); +}); + +it('estimates pdf conversion cost through application action', function () { + $file = FileRecord::factory()->png()->create(); + $converter = app(ConverterRegistry::class)->find('png', 'pdf'); + + $cost = app(EstimateConversionCostAction::class)->handle($file, $converter, []); + + expect($cost->amount)->toBe(2); +}); From 2cc88b952fa96294e4ab6aaad8944afa8f602028 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:31:40 +0300 Subject: [PATCH 14/22] CONV-228: Create ConversionCreditCharge model and migration Co-Authored-By: Claude Sonnet 4.6 --- app/Enums/ConversionCreditChargeStatus.php | 13 +++++ app/Models/ConversionCreditCharge.php | 42 ++++++++++++++ .../ConversionCreditChargeFactory.php | 56 +++++++++++++++++++ ...create_conversion_credit_charges_table.php | 31 ++++++++++ .../Models/ConversionCreditChargeTest.php | 48 ++++++++++++++++ 5 files changed, 190 insertions(+) create mode 100644 app/Enums/ConversionCreditChargeStatus.php create mode 100644 app/Models/ConversionCreditCharge.php create mode 100644 database/factories/ConversionCreditChargeFactory.php create mode 100644 database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php create mode 100644 tests/Feature/Models/ConversionCreditChargeTest.php diff --git a/app/Enums/ConversionCreditChargeStatus.php b/app/Enums/ConversionCreditChargeStatus.php new file mode 100644 index 0000000..a9d1406 --- /dev/null +++ b/app/Enums/ConversionCreditChargeStatus.php @@ -0,0 +1,13 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'conversion_job_id', + 'estimated_amount', + 'captured_amount', + 'refunded_amount', + 'status', + 'breakdown_json', + ]; + + protected $casts = [ + 'status' => ConversionCreditChargeStatus::class, + 'breakdown_json' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function conversionJob(): BelongsTo + { + return $this->belongsTo(ConversionJob::class); + } +} diff --git a/database/factories/ConversionCreditChargeFactory.php b/database/factories/ConversionCreditChargeFactory.php new file mode 100644 index 0000000..caf6498 --- /dev/null +++ b/database/factories/ConversionCreditChargeFactory.php @@ -0,0 +1,56 @@ + + */ +class ConversionCreditChargeFactory extends Factory +{ + protected $model = ConversionCreditCharge::class; + + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'conversion_job_id' => null, + 'estimated_amount' => 1, + 'captured_amount' => 0, + 'refunded_amount' => 0, + 'status' => ConversionCreditChargeStatus::Estimated, + 'breakdown_json' => null, + ]; + } + + public function estimated(): static + { + return $this->state([ + 'status' => ConversionCreditChargeStatus::Estimated, + 'captured_amount' => 0, + ]); + } + + public function captured(): static + { + return $this->state(fn (array $attributes) => [ + 'status' => ConversionCreditChargeStatus::Captured, + 'captured_amount' => $attributes['estimated_amount'], + ]); + } + + public function failed(): static + { + return $this->state([ + 'status' => ConversionCreditChargeStatus::Failed, + 'captured_amount' => 0, + ]); + } +} diff --git a/database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php b/database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php new file mode 100644 index 0000000..0c3b117 --- /dev/null +++ b/database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('conversion_job_id')->nullable()->constrained()->nullOnDelete(); + $table->unsignedInteger('estimated_amount'); + $table->unsignedInteger('captured_amount')->default(0); + $table->unsignedInteger('refunded_amount')->default(0); + $table->string('status'); + $table->json('breakdown_json')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'status']); + $table->index('conversion_job_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('conversion_credit_charges'); + } +}; diff --git a/tests/Feature/Models/ConversionCreditChargeTest.php b/tests/Feature/Models/ConversionCreditChargeTest.php new file mode 100644 index 0000000..a6ecde0 --- /dev/null +++ b/tests/Feature/Models/ConversionCreditChargeTest.php @@ -0,0 +1,48 @@ +create([ + 'estimated_amount' => 2, + 'captured_amount' => 0, + 'status' => ConversionCreditChargeStatus::Estimated, + ]); + + expect($charge->exists)->toBeTrue(); + expect($charge->estimated_amount)->toBe(2); + expect($charge->captured_amount)->toBe(0); + expect($charge->status)->toBe(ConversionCreditChargeStatus::Estimated); +}); + +it('belongs to a user', function () { + $charge = ConversionCreditCharge::factory()->create(); + + expect($charge->user)->not->toBeNull(); +}); + +it('can be linked to a conversion job', function () { + $charge = ConversionCreditCharge::factory() + ->for(\App\Models\ConversionJob::factory(), 'conversionJob') + ->create(); + + expect($charge->conversion_job_id)->not->toBeNull(); + expect($charge->conversionJob)->not->toBeNull(); +}); + +it('captured state sets captured amount equal to estimated', function () { + $charge = ConversionCreditCharge::factory()->create([ + 'estimated_amount' => 2, + ]); + + $charge->forceFill([ + 'captured_amount' => $charge->estimated_amount, + 'status' => ConversionCreditChargeStatus::Captured, + ])->save(); + + expect($charge->fresh()->status)->toBe(ConversionCreditChargeStatus::Captured); + expect($charge->fresh()->captured_amount)->toBe(2); +}); From 1717d07741ae15bd3001b87cd3c162f282fc0a51 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:32:48 +0300 Subject: [PATCH 15/22] CONV-229: Test conversion job checks credits before queue Co-Authored-By: Claude Sonnet 4.6 --- .../CreateConversionJobActionCreditTest.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/Feature/Actions/CreateConversionJobActionCreditTest.php diff --git a/tests/Feature/Actions/CreateConversionJobActionCreditTest.php b/tests/Feature/Actions/CreateConversionJobActionCreditTest.php new file mode 100644 index 0000000..8a029d9 --- /dev/null +++ b/tests/Feature/Actions/CreateConversionJobActionCreditTest.php @@ -0,0 +1,70 @@ +create(); + $user->creditAccount->forceFill(['balance' => 1])->save(); + + $file = FileRecord::factory()->png()->for($user)->create(); + + app(CreateConversionJobAction::class)->handle( + user: $user, + sourceFile: $file, + targetFormat: 'pdf', + options: [], + ); +})->throws(InsufficientCreditsException::class); + +it('does not dispatch queue job when credits are insufficient', function () { + Queue::fake(); + + $user = User::factory()->create(); + $user->creditAccount->forceFill(['balance' => 1])->save(); + + $file = FileRecord::factory()->png()->for($user)->create(); + + try { + app(CreateConversionJobAction::class)->handle( + user: $user, + sourceFile: $file, + targetFormat: 'pdf', + options: [], + ); + } catch (InsufficientCreditsException) { + // expected + } + + expect(ConversionJob::query()->count())->toBe(0); + Queue::assertNothingPushed(); +}); + +it('creates conversion job when user has enough credits', function () { + Queue::fake(); + + $user = User::factory()->create(); + app(CreditLedger::class)->grant($user, 10, 'test'); + + $file = FileRecord::factory()->png()->for($user)->create(); + + $job = app(CreateConversionJobAction::class)->handle( + user: $user, + sourceFile: $file, + targetFormat: 'jpg', + options: [], + ); + + expect($job->exists)->toBeTrue(); + expect(ConversionJob::query()->count())->toBe(1); + Queue::assertPushed(\App\Jobs\ProcessConversionJob::class); +}); From cf6bfaa487d16abd91d6f63f806001451d9972c9 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:33:42 +0300 Subject: [PATCH 16/22] CONV-230: Enforce credits in CreateConversionJobAction Co-Authored-By: Claude Sonnet 4.6 --- .../Conversions/CreateConversionJobAction.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/Actions/Conversions/CreateConversionJobAction.php b/app/Actions/Conversions/CreateConversionJobAction.php index a5388ce..3fcc8e2 100644 --- a/app/Actions/Conversions/CreateConversionJobAction.php +++ b/app/Actions/Conversions/CreateConversionJobAction.php @@ -4,9 +4,13 @@ namespace App\Actions\Conversions; +use App\Contracts\Billing\CreditLedger; +use App\Enums\ConversionCreditChargeStatus; use App\Enums\ConversionStatus; use App\Enums\FileFormat; +use App\Exceptions\Billing\InsufficientCreditsException; use App\Jobs\ProcessConversionJob; +use App\Models\ConversionCreditCharge; use App\Models\ConversionJob; use App\Models\FileRecord; use App\Models\User; @@ -18,6 +22,8 @@ final class CreateConversionJobAction { public function __construct( private readonly ConverterRegistry $converterRegistry, + private readonly EstimateConversionCostAction $estimateCost, + private readonly CreditLedger $creditLedger, ) {} /** @@ -48,6 +54,17 @@ public function handle( $normalizedOptions = $converter->validateOptions($options); + $cost = $this->estimateCost->handle($sourceFile, $converter, $normalizedOptions); + + $balance = $this->creditLedger->balance($user); + + if ($balance < $cost->amount) { + throw InsufficientCreditsException::make( + required: $cost->amount, + available: $balance, + ); + } + $job = ConversionJob::create([ 'user_id' => $user->id, 'source_file_id' => $sourceFile->id, @@ -62,6 +79,16 @@ public function handle( 'progress' => 0, ]); + ConversionCreditCharge::create([ + 'user_id' => $user->id, + 'conversion_job_id' => $job->id, + 'estimated_amount' => $cost->amount, + 'captured_amount' => 0, + 'refunded_amount' => 0, + 'status' => ConversionCreditChargeStatus::Estimated, + 'breakdown_json' => $cost->breakdown, + ]); + ProcessConversionJob::dispatch($job->id); return $job; From 09c04cacf3aad26f96ef3b2ece8b0ced887ce4ef Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:34:54 +0300 Subject: [PATCH 17/22] CONV-231: Test credits captured after successful conversion Co-Authored-By: Claude Sonnet 4.6 --- app/Models/ConversionJob.php | 6 +++ database/factories/ConversionJobFactory.php | 10 ++++ .../Jobs/ProcessConversionJobCreditTest.php | 50 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 tests/Feature/Jobs/ProcessConversionJobCreditTest.php diff --git a/app/Models/ConversionJob.php b/app/Models/ConversionJob.php index 2cb5d6d..d337a65 100644 --- a/app/Models/ConversionJob.php +++ b/app/Models/ConversionJob.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Carbon; /** @@ -82,6 +83,11 @@ public function resultFile(): BelongsTo return $this->belongsTo(FileRecord::class, 'result_file_id'); } + public function creditCharge(): HasOne + { + return $this->hasOne(ConversionCreditCharge::class); + } + public function isCompleted(): bool { return $this->status === ConversionStatus::Completed; diff --git a/database/factories/ConversionJobFactory.php b/database/factories/ConversionJobFactory.php index 36120d0..3d31fc1 100644 --- a/database/factories/ConversionJobFactory.php +++ b/database/factories/ConversionJobFactory.php @@ -37,6 +37,16 @@ public function definition(): array ]; } + public function pngToJpg(): static + { + return $this->state([ + 'source_format' => 'png', + 'target_format' => 'jpg', + 'converter_key' => 'png_to_jpg', + 'options_json' => ['quality' => 'high'], + ]); + } + public function queued(): static { return $this->state(['status' => ConversionStatus::Queued, 'progress' => 0]); diff --git a/tests/Feature/Jobs/ProcessConversionJobCreditTest.php b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php new file mode 100644 index 0000000..4201c51 --- /dev/null +++ b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php @@ -0,0 +1,50 @@ +create(); + $user->creditAccount->forceFill(['balance' => 10])->save(); + + $job = ConversionJob::factory() + ->pngToJpg() + ->queued() + ->for($user) + ->create(); + + ConversionCreditCharge::factory() + ->for($user) + ->for($job, 'conversionJob') + ->create([ + 'estimated_amount' => 1, + 'captured_amount' => 0, + 'status' => ConversionCreditChargeStatus::Estimated, + ]); + + app()->instance( + ConverterDriverRegistry::class, + new ConverterDriverRegistry([new FakeConverterDriver('png_to_jpg')]), + ); + + (new ProcessConversionJob($job->id))->handle( + app(ConverterDriverRegistry::class), + app(RecordConversionResultFileAction::class), + ); + + expect(app(CreditLedger::class)->balance($user))->toBe(9); + expect($job->creditCharge->fresh()->status)->toBe(ConversionCreditChargeStatus::Captured); + expect($job->creditCharge->fresh()->captured_amount)->toBe(1); +}); From e26372f483c0029f8bec900039bc63f4de5b72c0 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:36:12 +0300 Subject: [PATCH 18/22] CONV-232: Capture credits on successful conversion Co-Authored-By: Claude Sonnet 4.6 --- app/Jobs/ProcessConversionJob.php | 47 +++++++++++++++++++ .../Jobs/ProcessConversionJobCreditTest.php | 1 + .../Feature/Jobs/ProcessConversionJobTest.php | 4 ++ 3 files changed, 52 insertions(+) diff --git a/app/Jobs/ProcessConversionJob.php b/app/Jobs/ProcessConversionJob.php index ca999df..8912cd7 100644 --- a/app/Jobs/ProcessConversionJob.php +++ b/app/Jobs/ProcessConversionJob.php @@ -5,6 +5,8 @@ namespace App\Jobs; use App\Actions\Conversions\RecordConversionResultFileAction; +use App\Contracts\Billing\CreditLedger; +use App\Enums\ConversionCreditChargeStatus; use App\Enums\ConversionStatus; use App\Models\ConversionJob; use App\Support\Conversions\ConverterDriverRegistry; @@ -14,6 +16,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Throwable; @@ -31,6 +34,7 @@ public function __construct( public function handle( ConverterDriverRegistry $drivers, RecordConversionResultFileAction $recorder, + CreditLedger $creditLedger, ): void { $job = ConversionJob::find($this->conversionJobId); @@ -64,6 +68,8 @@ public function handle( 'progress' => 100, 'completed_at' => now(), ])->save(); + + $this->captureCredits($job, $creditLedger); } catch (Throwable $exception) { $job->forceFill([ 'status' => ConversionStatus::Failed, @@ -72,7 +78,48 @@ public function handle( 'completed_at' => now(), ])->save(); + $this->markChargeFailed($job); + report($exception); } } + + private function captureCredits(ConversionJob $job, CreditLedger $creditLedger): void + { + $charge = $job->creditCharge; + + if ($charge === null) { + return; + } + + DB::transaction(function () use ($job, $charge, $creditLedger) { + $creditLedger->spend( + user: $job->user, + amount: $charge->estimated_amount, + reason: 'conversion_completed', + meta: [ + 'conversion_job_id' => $job->id, + 'converter_key' => $job->converter_key, + ], + ); + + $charge->forceFill([ + 'captured_amount' => $charge->estimated_amount, + 'status' => ConversionCreditChargeStatus::Captured, + ])->save(); + }); + } + + private function markChargeFailed(ConversionJob $job): void + { + $charge = $job->creditCharge; + + if ($charge === null) { + return; + } + + $charge->forceFill([ + 'status' => ConversionCreditChargeStatus::Failed, + ])->save(); + } } diff --git a/tests/Feature/Jobs/ProcessConversionJobCreditTest.php b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php index 4201c51..94dd5fc 100644 --- a/tests/Feature/Jobs/ProcessConversionJobCreditTest.php +++ b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php @@ -42,6 +42,7 @@ (new ProcessConversionJob($job->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); expect(app(CreditLedger::class)->balance($user))->toBe(9); diff --git a/tests/Feature/Jobs/ProcessConversionJobTest.php b/tests/Feature/Jobs/ProcessConversionJobTest.php index 001ed6e..1dad88f 100644 --- a/tests/Feature/Jobs/ProcessConversionJobTest.php +++ b/tests/Feature/Jobs/ProcessConversionJobTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\Conversions\RecordConversionResultFileAction; +use App\Contracts\Billing\CreditLedger; use App\Enums\ConversionStatus; use App\Jobs\ProcessConversionJob; use App\Models\ConversionJob; @@ -43,6 +44,7 @@ (new ProcessConversionJob($conversionJob->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); $fresh = $conversionJob->fresh(); @@ -85,6 +87,7 @@ (new ProcessConversionJob($conversionJob->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); $fresh = $conversionJob->fresh(); @@ -118,6 +121,7 @@ (new ProcessConversionJob($conversionJob->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); expect($conversionJob->fresh()->status)->toBe(ConversionStatus::Completed); From 9f0ffc32c7f663dd97a6d78a93d0656c309da59b Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:36:45 +0300 Subject: [PATCH 19/22] CONV-233: Test failed conversion does not spend credits Co-Authored-By: Claude Sonnet 4.6 --- .../Jobs/ProcessConversionJobCreditTest.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Feature/Jobs/ProcessConversionJobCreditTest.php b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php index 94dd5fc..f793364 100644 --- a/tests/Feature/Jobs/ProcessConversionJobCreditTest.php +++ b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php @@ -49,3 +49,40 @@ expect($job->creditCharge->fresh()->status)->toBe(ConversionCreditChargeStatus::Captured); expect($job->creditCharge->fresh()->captured_amount)->toBe(1); }); + +it('does not spend credits when conversion fails', function () { + Storage::fake('local'); + + $user = User::factory()->create(); + $user->creditAccount->forceFill(['balance' => 10])->save(); + + $job = ConversionJob::factory() + ->pngToJpg() + ->queued() + ->for($user) + ->create(); + + ConversionCreditCharge::factory() + ->for($user) + ->for($job, 'conversionJob') + ->create([ + 'estimated_amount' => 1, + 'captured_amount' => 0, + 'status' => ConversionCreditChargeStatus::Estimated, + ]); + + app()->instance( + ConverterDriverRegistry::class, + new ConverterDriverRegistry([new FakeConverterDriver('png_to_jpg', shouldFail: true)]), + ); + + (new ProcessConversionJob($job->id))->handle( + app(ConverterDriverRegistry::class), + app(RecordConversionResultFileAction::class), + app(CreditLedger::class), + ); + + expect(app(CreditLedger::class)->balance($user))->toBe(10); + expect($job->creditCharge->fresh()->status)->toBe(ConversionCreditChargeStatus::Failed); + expect($job->creditCharge->fresh()->captured_amount)->toBe(0); +}); From 8d06c8a3e0eaf8694ba20b307495d7d187f9c3c3 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:38:54 +0300 Subject: [PATCH 20/22] CONV-234: Show real estimated cost in settings step Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Dashboard/DashboardConverter.php | 22 ++++++++++++++ .../dashboard/dashboard-converter.blade.php | 10 +++++-- .../Livewire/DashboardConverterCostTest.php | 30 +++++++++++++++++++ .../DashboardConverterSettingsStepTest.php | 6 ++-- 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 tests/Feature/Livewire/DashboardConverterCostTest.php diff --git a/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index a657987..86a2d0b 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -3,8 +3,10 @@ namespace App\Livewire\Dashboard; use App\Actions\Conversions\CreateConversionJobAction; +use App\Actions\Conversions\EstimateConversionCostAction; use App\Actions\Files\StoreUploadedFileAction; use App\Enums\ConversionStatus; +use App\Exceptions\Billing\InsufficientCreditsException; use App\Exceptions\Files\FileStorageException; use App\Exceptions\Files\UnsupportedFileFormatException; use App\Exceptions\Storage\StorageLimitExceededException; @@ -60,6 +62,8 @@ class DashboardConverter extends Component public ?string $convertError = null; + public ?int $estimatedCreditCost = null; + public function updatedUpload(): void { $this->storeUpload(app(StoreUploadedFileAction::class), app(FeatureAccessService::class)); @@ -190,6 +194,17 @@ public function selectTargetFormat(string $targetFormat): void $this->initializeOptionsFromSchema(); } + try { + $cost = app(EstimateConversionCostAction::class)->handle( + $this->currentFile, + $converter, + $this->options, + ); + $this->estimatedCreditCost = $cost->amount; + } catch (\Throwable) { + $this->estimatedCreditCost = null; + } + $this->step = 'settings'; } @@ -244,6 +259,12 @@ public function convert(): void $this->convertError = 'This conversion is not supported. Please choose a different format.'; $this->step = 'format'; + return; + } catch (InsufficientCreditsException $e) { + $this->convertError = 'Not enough credits. This conversion requires ' + .($this->estimatedCreditCost ?? '?') + .' credit(s). Please top up your balance.'; + return; } catch (Throwable) { $this->convertError = 'Something went wrong. Please try again.'; @@ -338,6 +359,7 @@ private function resetTargetSelection(): void $this->optionsSchema = []; $this->options = []; $this->optionsByTarget = []; + $this->estimatedCreditCost = null; } public function getCurrentJobProperty(): ?ConversionJob diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index cefcec1..e7074df 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -168,10 +168,16 @@ class="flex items-start gap-3 rounded-[var(--ca-radius-md)] border border-[var(- @include('livewire.dashboard.dashboard-converter.partials.dynamic-options-form')
Estimated cost -

Credit cost will be calculated before conversion.

+ @if ($estimatedCreditCost !== null) +

+ {{ $estimatedCreditCost }} {{ $estimatedCreditCost === 1 ? 'credit' : 'credits' }} +

+ @else +

Credit cost will be calculated before conversion.

+ @endif
diff --git a/tests/Feature/Livewire/DashboardConverterCostTest.php b/tests/Feature/Livewire/DashboardConverterCostTest.php new file mode 100644 index 0000000..b0d7ec6 --- /dev/null +++ b/tests/Feature/Livewire/DashboardConverterCostTest.php @@ -0,0 +1,30 @@ +create(); + $file = FileRecord::factory()->png()->for($user)->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('currentFileId', $file->id) + ->call('selectTargetFormat', 'pdf') + ->assertSee('2 credits'); +}); + +it('shows 1 credit cost for image to image conversion', function () { + $user = User::factory()->create(); + $file = FileRecord::factory()->png()->for($user)->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('currentFileId', $file->id) + ->call('selectTargetFormat', 'jpg') + ->assertSee('1 credit'); +}); diff --git a/tests/Feature/Livewire/DashboardConverterSettingsStepTest.php b/tests/Feature/Livewire/DashboardConverterSettingsStepTest.php index 759f810..fcc0606 100644 --- a/tests/Feature/Livewire/DashboardConverterSettingsStepTest.php +++ b/tests/Feature/Livewire/DashboardConverterSettingsStepTest.php @@ -85,16 +85,16 @@ ->assertSee('Background color'); }); -it('shows the estimated cost placeholder on the settings step', function () { +it('shows the estimated cost on the settings step', function () { $user = User::factory()->create(); - $file = FileRecord::factory()->for($user)->create(['extension' => 'png']); + $file = FileRecord::factory()->png()->for($user)->create(); Livewire::actingAs($user) ->test(DashboardConverter::class) ->set('currentFileId', $file->id) ->call('selectTargetFormat', 'jpg') ->assertSee('Estimated cost') - ->assertSee('calculated before conversion'); + ->assertSee('1 credit'); }); it('renders the png to jpg settings form', function () { From b969ca10a53c81851adb7778a4edff38345261f2 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 15:41:13 +0300 Subject: [PATCH 21/22] CONV-235: Add insufficient credits UI state Co-Authored-By: Claude Sonnet 4.6 --- app/Livewire/Dashboard/DashboardConverter.php | 2 +- .../ConversionCreditChargeFactory.php | 1 - .../dashboard/dashboard-converter.blade.php | 7 ++++++ .../CreateConversionJobActionCreditTest.php | 3 ++- .../ProcessConversionJobRealDriverTest.php | 4 ++++ tests/Feature/ConversionFlowTest.php | 2 ++ .../Livewire/DashboardConverterCostTest.php | 22 +++++++++++++++++++ .../Models/ConversionCreditChargeTest.php | 3 ++- 8 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index 86a2d0b..0207621 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -201,7 +201,7 @@ public function selectTargetFormat(string $targetFormat): void $this->options, ); $this->estimatedCreditCost = $cost->amount; - } catch (\Throwable) { + } catch (Throwable) { $this->estimatedCreditCost = null; } diff --git a/database/factories/ConversionCreditChargeFactory.php b/database/factories/ConversionCreditChargeFactory.php index caf6498..74d7688 100644 --- a/database/factories/ConversionCreditChargeFactory.php +++ b/database/factories/ConversionCreditChargeFactory.php @@ -6,7 +6,6 @@ use App\Enums\ConversionCreditChargeStatus; use App\Models\ConversionCreditCharge; -use App\Models\ConversionJob; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index e7074df..e47819b 100644 --- a/resources/views/livewire/dashboard/dashboard-converter.blade.php +++ b/resources/views/livewire/dashboard/dashboard-converter.blade.php @@ -270,6 +270,13 @@ class="inline-flex w-full items-center justify-center gap-2 rounded-[var(--ca-ra ← Back
+ @if ($convertError) +
+

Not enough credits

+

{{ $convertError }}

+
+ @endif +

Ready to convert {{ strtoupper($file->extension) }} to {{ strtoupper($selectedTargetFormat) }} diff --git a/tests/Feature/Actions/CreateConversionJobActionCreditTest.php b/tests/Feature/Actions/CreateConversionJobActionCreditTest.php index 8a029d9..0784dc5 100644 --- a/tests/Feature/Actions/CreateConversionJobActionCreditTest.php +++ b/tests/Feature/Actions/CreateConversionJobActionCreditTest.php @@ -5,6 +5,7 @@ use App\Actions\Conversions\CreateConversionJobAction; use App\Contracts\Billing\CreditLedger; use App\Exceptions\Billing\InsufficientCreditsException; +use App\Jobs\ProcessConversionJob; use App\Models\ConversionJob; use App\Models\FileRecord; use App\Models\User; @@ -66,5 +67,5 @@ expect($job->exists)->toBeTrue(); expect(ConversionJob::query()->count())->toBe(1); - Queue::assertPushed(\App\Jobs\ProcessConversionJob::class); + Queue::assertPushed(ProcessConversionJob::class); }); diff --git a/tests/Feature/Conversion/ProcessConversionJobRealDriverTest.php b/tests/Feature/Conversion/ProcessConversionJobRealDriverTest.php index b879dc1..75b37c9 100644 --- a/tests/Feature/Conversion/ProcessConversionJobRealDriverTest.php +++ b/tests/Feature/Conversion/ProcessConversionJobRealDriverTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\Conversions\RecordConversionResultFileAction; +use App\Contracts\Billing\CreditLedger; use App\Enums\ConversionStatus; use App\Jobs\ProcessConversionJob; use App\Models\ConversionJob; @@ -40,6 +41,7 @@ (new ProcessConversionJob($job->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); $fresh = $job->fresh(); @@ -76,6 +78,7 @@ (new ProcessConversionJob($job->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); $fresh = $job->fresh(); @@ -113,6 +116,7 @@ (new ProcessConversionJob($job->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); $fresh = $job->fresh(); diff --git a/tests/Feature/ConversionFlowTest.php b/tests/Feature/ConversionFlowTest.php index 72e20b2..4a4963a 100644 --- a/tests/Feature/ConversionFlowTest.php +++ b/tests/Feature/ConversionFlowTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Actions\Conversions\RecordConversionResultFileAction; +use App\Contracts\Billing\CreditLedger; use App\Jobs\ProcessConversionJob; use App\Livewire\Dashboard\DashboardConverter; use App\Models\ConversionJob; @@ -34,6 +35,7 @@ (new ProcessConversionJob($job->id))->handle( app(ConverterDriverRegistry::class), app(RecordConversionResultFileAction::class), + app(CreditLedger::class), ); $component diff --git a/tests/Feature/Livewire/DashboardConverterCostTest.php b/tests/Feature/Livewire/DashboardConverterCostTest.php index b0d7ec6..82116c8 100644 --- a/tests/Feature/Livewire/DashboardConverterCostTest.php +++ b/tests/Feature/Livewire/DashboardConverterCostTest.php @@ -3,8 +3,10 @@ declare(strict_types=1); use App\Livewire\Dashboard\DashboardConverter; +use App\Models\ConversionJob; use App\Models\FileRecord; use App\Models\User; +use Illuminate\Support\Facades\Queue; use Livewire\Livewire; it('shows estimated conversion cost in settings step', function () { @@ -28,3 +30,23 @@ ->call('selectTargetFormat', 'jpg') ->assertSee('1 credit'); }); + +it('shows insufficient credits message and does not create job', function () { + Queue::fake(); + + $user = User::factory()->create(); + $user->creditAccount->forceFill(['balance' => 1])->save(); + + $file = FileRecord::factory()->png()->for($user)->create(); + + Livewire::actingAs($user) + ->test(DashboardConverter::class) + ->set('currentFileId', $file->id) + ->call('selectTargetFormat', 'pdf') + ->call('continueFromSettings') + ->call('convert') + ->assertSee('Not enough credits'); + + expect(ConversionJob::query()->count())->toBe(0); + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/Models/ConversionCreditChargeTest.php b/tests/Feature/Models/ConversionCreditChargeTest.php index a6ecde0..496f145 100644 --- a/tests/Feature/Models/ConversionCreditChargeTest.php +++ b/tests/Feature/Models/ConversionCreditChargeTest.php @@ -4,6 +4,7 @@ use App\Enums\ConversionCreditChargeStatus; use App\Models\ConversionCreditCharge; +use App\Models\ConversionJob; it('creates conversion credit charge record', function () { $charge = ConversionCreditCharge::factory()->create([ @@ -26,7 +27,7 @@ it('can be linked to a conversion job', function () { $charge = ConversionCreditCharge::factory() - ->for(\App\Models\ConversionJob::factory(), 'conversionJob') + ->for(ConversionJob::factory(), 'conversionJob') ->create(); expect($charge->conversion_job_id)->not->toBeNull(); From 89413c37af03ad4c81dea1be45ecc9385bc03e52 Mon Sep 17 00:00:00 2001 From: Ivan Moroz Date: Tue, 2 Jun 2026 16:38:29 +0300 Subject: [PATCH 22/22] Fix code review issues from Phase 15 - Add integer casts for amount fields in ConversionCreditCharge to prevent TypeError - Validate CreditCostBreakdown total consistency (must equal base + size + features) - Fail fast in ConfigDrivenConversionCostEstimator when pricing rule is missing or non-numeric - Add unique constraint migration for conversion_credit_charges.conversion_job_id - Fix P1: isolate captureCredits so a billing failure cannot override a successful job status - Make captureCredits idempotent via lockForUpdate + status pre-check - Wrap ConversionJob + ConversionCreditCharge creation in DB::transaction - Replace broad Throwable catch in DashboardConverter with specific billing exceptions Co-Authored-By: Claude Sonnet 4.6 --- .../Conversions/CreateConversionJobAction.php | 54 +++++++++++-------- app/Data/Credits/CreditCostBreakdown.php | 5 ++ app/Jobs/ProcessConversionJob.php | 24 +++++++-- app/Livewire/Dashboard/DashboardConverter.php | 4 +- app/Models/ConversionCreditCharge.php | 3 ++ .../ConfigDrivenConversionCostEstimator.php | 11 +++- ...rsion_credit_charges_conversion_job_id.php | 30 +++++++++++ 7 files changed, 102 insertions(+), 29 deletions(-) create mode 100644 database/migrations/2026_06_02_133059_add_unique_constraint_to_conversion_credit_charges_conversion_job_id.php diff --git a/app/Actions/Conversions/CreateConversionJobAction.php b/app/Actions/Conversions/CreateConversionJobAction.php index 3fcc8e2..052eb4c 100644 --- a/app/Actions/Conversions/CreateConversionJobAction.php +++ b/app/Actions/Conversions/CreateConversionJobAction.php @@ -17,6 +17,7 @@ use App\Support\Conversions\Exceptions\UnsupportedConversionException; use App\Support\Converters\ConverterRegistry; use App\Support\Converters\Exceptions\UnsupportedFormatException; +use Illuminate\Support\Facades\DB; final class CreateConversionJobAction { @@ -65,29 +66,36 @@ public function handle( ); } - $job = ConversionJob::create([ - 'user_id' => $user->id, - 'source_file_id' => $sourceFile->id, - 'source_format' => $sourceFormat, - 'target_format' => $normalizedTarget, - // Driver-registry key (e.g. "png_to_jpg") — distinct from the catalog - // Converter::key() ("png:jpg"). ConverterDriverRegistry::findOrFail() - // resolves drivers on this value, so it must match driver keys. - 'converter_key' => "{$sourceFormat}_to_{$normalizedTarget}", - 'options_json' => $normalizedOptions, - 'status' => ConversionStatus::Queued, - 'progress' => 0, - ]); - - ConversionCreditCharge::create([ - 'user_id' => $user->id, - 'conversion_job_id' => $job->id, - 'estimated_amount' => $cost->amount, - 'captured_amount' => 0, - 'refunded_amount' => 0, - 'status' => ConversionCreditChargeStatus::Estimated, - 'breakdown_json' => $cost->breakdown, - ]); + // Wrap job + charge creation in a transaction so a failed charge insert + // cannot leave an orphan queued job. Dispatch happens after commit so + // the worker never picks up a job whose charge record does not exist. + $job = DB::transaction(function () use ($user, $sourceFile, $sourceFormat, $normalizedTarget, $normalizedOptions, $cost) { + $job = ConversionJob::create([ + 'user_id' => $user->id, + 'source_file_id' => $sourceFile->id, + 'source_format' => $sourceFormat, + 'target_format' => $normalizedTarget, + // Driver-registry key (e.g. "png_to_jpg") — distinct from the catalog + // Converter::key() ("png:jpg"). ConverterDriverRegistry::findOrFail() + // resolves drivers on this value, so it must match driver keys. + 'converter_key' => "{$sourceFormat}_to_{$normalizedTarget}", + 'options_json' => $normalizedOptions, + 'status' => ConversionStatus::Queued, + 'progress' => 0, + ]); + + ConversionCreditCharge::create([ + 'user_id' => $user->id, + 'conversion_job_id' => $job->id, + 'estimated_amount' => $cost->amount, + 'captured_amount' => 0, + 'refunded_amount' => 0, + 'status' => ConversionCreditChargeStatus::Estimated, + 'breakdown_json' => $cost->breakdown, + ]); + + return $job; + }); ProcessConversionJob::dispatch($job->id); diff --git a/app/Data/Credits/CreditCostBreakdown.php b/app/Data/Credits/CreditCostBreakdown.php index 2ac9dff..91d51d5 100644 --- a/app/Data/Credits/CreditCostBreakdown.php +++ b/app/Data/Credits/CreditCostBreakdown.php @@ -25,6 +25,11 @@ public function __construct( if ($this->total < 0) { throw new \InvalidArgumentException('Breakdown total cannot be negative.'); } + if ($this->total !== ($this->base + $this->size + $this->features)) { + throw new \InvalidArgumentException( + "Breakdown total ({$this->total}) must equal base + size + features ({$this->base} + {$this->size} + {$this->features})." + ); + } } public function toArray(): array diff --git a/app/Jobs/ProcessConversionJob.php b/app/Jobs/ProcessConversionJob.php index 8912cd7..a02c787 100644 --- a/app/Jobs/ProcessConversionJob.php +++ b/app/Jobs/ProcessConversionJob.php @@ -8,6 +8,7 @@ use App\Contracts\Billing\CreditLedger; use App\Enums\ConversionCreditChargeStatus; use App\Enums\ConversionStatus; +use App\Models\ConversionCreditCharge; use App\Models\ConversionJob; use App\Support\Conversions\ConverterDriverRegistry; use App\Support\Conversions\DTO\ConversionContext; @@ -69,7 +70,14 @@ public function handle( 'completed_at' => now(), ])->save(); - $this->captureCredits($job, $creditLedger); + // Capture credits separately — a billing failure must not undo a + // successful conversion, so we report and move on rather than + // letting the exception propagate to the failure catch block below. + try { + $this->captureCredits($job, $creditLedger); + } catch (Throwable $captureException) { + report($captureException); + } } catch (Throwable $exception) { $job->forceFill([ 'status' => ConversionStatus::Failed, @@ -93,9 +101,17 @@ private function captureCredits(ConversionJob $job, CreditLedger $creditLedger): } DB::transaction(function () use ($job, $charge, $creditLedger) { + // Lock the charge row and verify it is still in the expected state + // before spending, so duplicate executions are idempotent. + $lockedCharge = ConversionCreditCharge::lockForUpdate()->find($charge->id); + + if ($lockedCharge === null || $lockedCharge->status !== ConversionCreditChargeStatus::Estimated) { + return; + } + $creditLedger->spend( user: $job->user, - amount: $charge->estimated_amount, + amount: $lockedCharge->estimated_amount, reason: 'conversion_completed', meta: [ 'conversion_job_id' => $job->id, @@ -103,8 +119,8 @@ private function captureCredits(ConversionJob $job, CreditLedger $creditLedger): ], ); - $charge->forceFill([ - 'captured_amount' => $charge->estimated_amount, + $lockedCharge->forceFill([ + 'captured_amount' => $lockedCharge->estimated_amount, 'status' => ConversionCreditChargeStatus::Captured, ])->save(); }); diff --git a/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index 0207621..6469806 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -7,6 +7,7 @@ use App\Actions\Files\StoreUploadedFileAction; use App\Enums\ConversionStatus; use App\Exceptions\Billing\InsufficientCreditsException; +use App\Exceptions\Billing\UnsupportedConversionCostException; use App\Exceptions\Files\FileStorageException; use App\Exceptions\Files\UnsupportedFileFormatException; use App\Exceptions\Storage\StorageLimitExceededException; @@ -201,7 +202,8 @@ public function selectTargetFormat(string $targetFormat): void $this->options, ); $this->estimatedCreditCost = $cost->amount; - } catch (Throwable) { + } catch (UnsupportedConversionCostException|\InvalidArgumentException) { + // Known pricing failures — unsupported pair or misconfigured rule. $this->estimatedCreditCost = null; } diff --git a/app/Models/ConversionCreditCharge.php b/app/Models/ConversionCreditCharge.php index e73a68a..dd36b28 100644 --- a/app/Models/ConversionCreditCharge.php +++ b/app/Models/ConversionCreditCharge.php @@ -28,6 +28,9 @@ class ConversionCreditCharge extends Model protected $casts = [ 'status' => ConversionCreditChargeStatus::class, 'breakdown_json' => 'array', + 'estimated_amount' => 'integer', + 'captured_amount' => 'integer', + 'refunded_amount' => 'integer', ]; public function user(): BelongsTo diff --git a/app/Services/Billing/ConfigDrivenConversionCostEstimator.php b/app/Services/Billing/ConfigDrivenConversionCostEstimator.php index 64f6a47..26beeaf 100644 --- a/app/Services/Billing/ConfigDrivenConversionCostEstimator.php +++ b/app/Services/Billing/ConfigDrivenConversionCostEstimator.php @@ -34,7 +34,16 @@ public function estimate(FileRecord $file, Converter $converter, array $options private function makeCost(string $rule, FileRecord $file, Converter $converter): CreditCost { - $base = (int) config("conversion_costs.rules.{$rule}.base", 1); + $configKey = "conversion_costs.rules.{$rule}.base"; + $raw = config($configKey); + + if ($raw === null || ! is_numeric($raw)) { + throw new \InvalidArgumentException( + "Missing or invalid pricing rule: '{$configKey}'. Expected a numeric value." + ); + } + + $base = (int) $raw; $breakdown = new CreditCostBreakdown( base: $base, diff --git a/database/migrations/2026_06_02_133059_add_unique_constraint_to_conversion_credit_charges_conversion_job_id.php b/database/migrations/2026_06_02_133059_add_unique_constraint_to_conversion_credit_charges_conversion_job_id.php new file mode 100644 index 0000000..49e25b0 --- /dev/null +++ b/database/migrations/2026_06_02_133059_add_unique_constraint_to_conversion_credit_charges_conversion_job_id.php @@ -0,0 +1,30 @@ +dropIndex('conversion_credit_charges_conversion_job_id_index'); + $table->unique('conversion_job_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('conversion_credit_charges', function (Blueprint $table) { + $table->dropUnique('conversion_credit_charges_conversion_job_id_unique'); + $table->index('conversion_job_id'); + }); + } +};