diff --git a/app/Actions/Conversions/CreateConversionJobAction.php b/app/Actions/Conversions/CreateConversionJobAction.php index a5388ce..052eb4c 100644 --- a/app/Actions/Conversions/CreateConversionJobAction.php +++ b/app/Actions/Conversions/CreateConversionJobAction.php @@ -4,20 +4,27 @@ 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; 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 { public function __construct( private readonly ConverterRegistry $converterRegistry, + private readonly EstimateConversionCostAction $estimateCost, + private readonly CreditLedger $creditLedger, ) {} /** @@ -48,19 +55,47 @@ public function handle( $normalizedOptions = $converter->validateOptions($options); - $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, - ]); + $cost = $this->estimateCost->handle($sourceFile, $converter, $normalizedOptions); + + $balance = $this->creditLedger->balance($user); + + if ($balance < $cost->amount) { + throw InsufficientCreditsException::make( + required: $cost->amount, + available: $balance, + ); + } + + // 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/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/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/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/app/Data/Credits/CreditCostBreakdown.php b/app/Data/Credits/CreditCostBreakdown.php new file mode 100644 index 0000000..91d51d5 --- /dev/null +++ b/app/Data/Credits/CreditCostBreakdown.php @@ -0,0 +1,45 @@ +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.'); + } + 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 + { + return [ + 'base' => $this->base, + 'size' => $this->size, + 'features' => $this->features, + 'total' => $this->total, + 'details' => $this->details, + ]; + } +} 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 @@ +conversionJobId); @@ -64,6 +69,15 @@ public function handle( 'progress' => 100, 'completed_at' => now(), ])->save(); + + // 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, @@ -72,7 +86,56 @@ 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) { + // 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: $lockedCharge->estimated_amount, + reason: 'conversion_completed', + meta: [ + 'conversion_job_id' => $job->id, + 'converter_key' => $job->converter_key, + ], + ); + + $lockedCharge->forceFill([ + 'captured_amount' => $lockedCharge->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/app/Livewire/Dashboard/DashboardConverter.php b/app/Livewire/Dashboard/DashboardConverter.php index a657987..6469806 100644 --- a/app/Livewire/Dashboard/DashboardConverter.php +++ b/app/Livewire/Dashboard/DashboardConverter.php @@ -3,8 +3,11 @@ 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\Billing\UnsupportedConversionCostException; use App\Exceptions\Files\FileStorageException; use App\Exceptions\Files\UnsupportedFileFormatException; use App\Exceptions\Storage\StorageLimitExceededException; @@ -60,6 +63,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 +195,18 @@ public function selectTargetFormat(string $targetFormat): void $this->initializeOptionsFromSchema(); } + try { + $cost = app(EstimateConversionCostAction::class)->handle( + $this->currentFile, + $converter, + $this->options, + ); + $this->estimatedCreditCost = $cost->amount; + } catch (UnsupportedConversionCostException|\InvalidArgumentException) { + // Known pricing failures — unsupported pair or misconfigured rule. + $this->estimatedCreditCost = null; + } + $this->step = 'settings'; } @@ -244,6 +261,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 +361,7 @@ private function resetTargetSelection(): void $this->optionsSchema = []; $this->options = []; $this->optionsByTarget = []; + $this->estimatedCreditCost = null; } public function getCurrentJobProperty(): ?ConversionJob diff --git a/app/Models/ConversionCreditCharge.php b/app/Models/ConversionCreditCharge.php new file mode 100644 index 0000000..dd36b28 --- /dev/null +++ b/app/Models/ConversionCreditCharge.php @@ -0,0 +1,45 @@ + */ + 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', + 'estimated_amount' => 'integer', + 'captured_amount' => 'integer', + 'refunded_amount' => 'integer', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function conversionJob(): BelongsTo + { + return $this->belongsTo(ConversionJob::class); + } +} 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/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..26beeaf --- /dev/null +++ b/app/Services/Billing/ConfigDrivenConversionCostEstimator.php @@ -0,0 +1,77 @@ + $options + */ + public function estimate(FileRecord $file, Converter $converter, array $options = []): CreditCost + { + $source = $converter->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 + { + $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, + 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 + { + 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); + } +} 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/database/factories/ConversionCreditChargeFactory.php b/database/factories/ConversionCreditChargeFactory.php new file mode 100644 index 0000000..74d7688 --- /dev/null +++ b/database/factories/ConversionCreditChargeFactory.php @@ -0,0 +1,55 @@ + + */ +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/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/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/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/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'); + }); + } +}; diff --git a/resources/views/livewire/dashboard/dashboard-converter.blade.php b/resources/views/livewire/dashboard/dashboard-converter.blade.php index cefcec1..e47819b 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')
Credit cost will be calculated before conversion.
+ @if ($estimatedCreditCost !== null) ++ {{ $estimatedCreditCost }} {{ $estimatedCreditCost === 1 ? 'credit' : 'credits' }} +
+ @else +Credit cost will be calculated before conversion.
+ @endifNot enough credits
+{{ $convertError }}
+Ready to convert {{ strtoupper($file->extension) }} to {{ strtoupper($selectedTargetFormat) }} 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/Feature/Actions/CreateConversionJobActionCreditTest.php b/tests/Feature/Actions/CreateConversionJobActionCreditTest.php new file mode 100644 index 0000000..0784dc5 --- /dev/null +++ b/tests/Feature/Actions/CreateConversionJobActionCreditTest.php @@ -0,0 +1,71 @@ +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(ProcessConversionJob::class); +}); 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); +}); 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/Jobs/ProcessConversionJobCreditTest.php b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php new file mode 100644 index 0000000..f793364 --- /dev/null +++ b/tests/Feature/Jobs/ProcessConversionJobCreditTest.php @@ -0,0 +1,88 @@ +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), + app(CreditLedger::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); +}); + +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); +}); 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); diff --git a/tests/Feature/Livewire/DashboardConverterCostTest.php b/tests/Feature/Livewire/DashboardConverterCostTest.php new file mode 100644 index 0000000..82116c8 --- /dev/null +++ b/tests/Feature/Livewire/DashboardConverterCostTest.php @@ -0,0 +1,52 @@ +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'); +}); + +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/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 () { diff --git a/tests/Feature/Models/ConversionCreditChargeTest.php b/tests/Feature/Models/ConversionCreditChargeTest.php new file mode 100644 index 0000000..496f145 --- /dev/null +++ b/tests/Feature/Models/ConversionCreditChargeTest.php @@ -0,0 +1,49 @@ +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(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); +}); 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'); +}); 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(); +}); 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); 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); +}); 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); +}); diff --git a/tests/Unit/Services/ConversionCostEstimatorTest.php b/tests/Unit/Services/ConversionCostEstimatorTest.php new file mode 100644 index 0000000..c75fff3 --- /dev/null +++ b/tests/Unit/Services/ConversionCostEstimatorTest.php @@ -0,0 +1,78 @@ +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); +}); + +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); +}); + +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); +}); + +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'); + + $cost = app(ConversionCostEstimator::class)->estimate($file, $converter, []); + + expect($cost->breakdown)->toHaveKeys([ + 'base', + 'size', + 'features', + 'total', + 'details', + ]); + + expect($cost->breakdown['total'])->toBe($cost->amount); +});