Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c6e496c
Backmerge main v0.1.14-phase14-custom-credit-ledger into develop
menvil Jun 2, 2026
978b5fb
CONV-215: Create CreditCost DTO
menvil Jun 2, 2026
7912d00
Merge pull request #184 from menvil/feature/CONV-215-create-credit-co…
menvil Jun 2, 2026
b41f371
CONV-216: Create CreditCostBreakdown DTO
menvil Jun 2, 2026
8d2f33d
Merge pull request #185 from menvil/feature/CONV-216-create-credit-co…
menvil Jun 2, 2026
0f6bfea
CONV-217: Create ConversionCostEstimator contract
menvil Jun 2, 2026
db2de27
Merge pull request #186 from menvil/feature/CONV-217-create-conversio…
menvil Jun 2, 2026
b623a95
CONV-218: Create ConfigDrivenConversionCostEstimator skeleton
menvil Jun 2, 2026
a668eee
Merge pull request #187 from menvil/feature/CONV-218-create-config-dr…
menvil Jun 2, 2026
70ce0bd
CONV-219: Add conversion costs config
menvil Jun 2, 2026
d7611c0
Merge pull request #188 from menvil/feature/CONV-219-add-conversion-c…
menvil Jun 2, 2026
e737081
CONV-220: Test image to image cost
menvil Jun 2, 2026
db7ed52
Merge pull request #189 from menvil/feature/CONV-220-test-image-to-im…
menvil Jun 2, 2026
9ea385e
CONV-221: Implement image to image cost
menvil Jun 2, 2026
eba9622
Merge pull request #190 from menvil/feature/CONV-221-implement-image-…
menvil Jun 2, 2026
20d2b62
CONV-222: Test image to PDF cost
menvil Jun 2, 2026
92e6f04
Merge pull request #191 from menvil/feature/CONV-222-test-image-to-pd…
menvil Jun 2, 2026
3c2c0f8
CONV-223: Implement image to PDF cost
menvil Jun 2, 2026
c990843
Merge pull request #192 from menvil/feature/CONV-223-implement-image-…
menvil Jun 2, 2026
f9d9add
CONV-224: Test cost breakdown shape
menvil Jun 2, 2026
0d2622a
Merge pull request #193 from menvil/feature/CONV-224-test-cost-breakd…
menvil Jun 2, 2026
2f51528
CONV-225: Implement cost breakdown
menvil Jun 2, 2026
8a84417
Merge pull request #194 from menvil/feature/CONV-225-implement-cost-b…
menvil Jun 2, 2026
f877c5b
CONV-226: Test unsupported cost estimation is rejected
menvil Jun 2, 2026
14f6e2b
Merge pull request #195 from menvil/feature/CONV-226-test-unsupported…
menvil Jun 2, 2026
facc74e
CONV-227: Create EstimateConversionCostAction
menvil Jun 2, 2026
59ec259
Merge pull request #196 from menvil/feature/CONV-227-create-estimate-…
menvil Jun 2, 2026
2cc88b9
CONV-228: Create ConversionCreditCharge model and migration
menvil Jun 2, 2026
b787f32
Merge pull request #197 from menvil/feature/CONV-228-create-conversio…
menvil Jun 2, 2026
1717d07
CONV-229: Test conversion job checks credits before queue
menvil Jun 2, 2026
7818b80
Merge pull request #198 from menvil/feature/CONV-229-test-conversion-…
menvil Jun 2, 2026
cf6bfaa
CONV-230: Enforce credits in CreateConversionJobAction
menvil Jun 2, 2026
4e2d5f3
Merge pull request #199 from menvil/feature/CONV-230-enforce-credits-…
menvil Jun 2, 2026
09c04ca
CONV-231: Test credits captured after successful conversion
menvil Jun 2, 2026
c6c3224
Merge pull request #200 from menvil/feature/CONV-231-test-credits-cap…
menvil Jun 2, 2026
e26372f
CONV-232: Capture credits on successful conversion
menvil Jun 2, 2026
f5befa1
Merge pull request #201 from menvil/feature/CONV-232-capture-credits-…
menvil Jun 2, 2026
9f0ffc3
CONV-233: Test failed conversion does not spend credits
menvil Jun 2, 2026
fda6150
Merge pull request #202 from menvil/feature/CONV-233-test-failed-conv…
menvil Jun 2, 2026
8d06c8a
CONV-234: Show real estimated cost in settings step
menvil Jun 2, 2026
c08985b
Merge pull request #203 from menvil/feature/CONV-234-show-real-estima…
menvil Jun 2, 2026
b969ca1
CONV-235: Add insufficient credits UI state
menvil Jun 2, 2026
9d5f474
Merge pull request #204 from menvil/feature/CONV-235-add-insufficient…
menvil Jun 2, 2026
89413c3
Fix code review issues from Phase 15
menvil Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 48 additions & 13 deletions app/Actions/Conversions/CreateConversionJobAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}

/**
Expand Down Expand Up @@ -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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Credit enforcement uses a non-atomic balance pre-check, so concurrent requests can over-queue jobs and fail later during capture.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/Actions/Conversions/CreateConversionJobAction.php, line 61:

<comment>Credit enforcement uses a non-atomic balance pre-check, so concurrent requests can over-queue jobs and fail later during capture.</comment>

<file context>
@@ -48,6 +54,17 @@ public function handle(
+
+        $balance = $this->creditLedger->balance($user);
+
+        if ($balance < $cost->amount) {
+            throw InsufficientCreditsException::make(
+                required: $cost->amount,
</file context>

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);

Expand Down
25 changes: 25 additions & 0 deletions app/Actions/Conversions/EstimateConversionCostAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Actions\Conversions;

use App\Contracts\Billing\ConversionCostEstimator;
use App\Data\Credits\CreditCost;
use App\Models\FileRecord;
use App\Support\Converters\Contracts\Converter;

final class EstimateConversionCostAction
{
public function __construct(
private readonly ConversionCostEstimator $estimator,
) {}

/**
* @param array<string, mixed> $options
*/
public function handle(FileRecord $file, Converter $converter, array $options = []): CreditCost
{
return $this->estimator->estimate($file, $converter, $options);
}
}
17 changes: 17 additions & 0 deletions app/Contracts/Billing/ConversionCostEstimator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Contracts\Billing;

use App\Data\Credits\CreditCost;
use App\Models\FileRecord;
use App\Support\Converters\Contracts\Converter;

interface ConversionCostEstimator
{
/**
* @param array<string, mixed> $options
*/
public function estimate(FileRecord $file, Converter $converter, array $options = []): CreditCost;
}
17 changes: 17 additions & 0 deletions app/Data/Credits/CreditCost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace App\Data\Credits;

final readonly class CreditCost
{
public function __construct(
public int $amount,
public array $breakdown,
) {
if ($this->amount < 0) {
throw new \InvalidArgumentException('Credit cost amount cannot be negative.');
}
}
}
45 changes: 45 additions & 0 deletions app/Data/Credits/CreditCostBreakdown.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace App\Data\Credits;

final readonly class CreditCostBreakdown
{
public function __construct(
public int $base,
public int $size,
public int $features,
public int $total,
public array $details,
) {
if ($this->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})."
);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

public function toArray(): array
{
return [
'base' => $this->base,
'size' => $this->size,
'features' => $this->features,
'total' => $this->total,
'details' => $this->details,
];
}
}
13 changes: 13 additions & 0 deletions app/Enums/ConversionCreditChargeStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace App\Enums;

enum ConversionCreditChargeStatus: string
{
case Estimated = 'estimated';
case Captured = 'captured';
case Refunded = 'refunded';
case Failed = 'failed';
}
15 changes: 15 additions & 0 deletions app/Exceptions/Billing/UnsupportedConversionCostException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Billing;

use DomainException;

final class UnsupportedConversionCostException extends DomainException
{
public static function forPair(string $source, string $target): self
{
return new self("Cannot estimate cost for unsupported conversion: {$source} → {$target}.");
}
}
63 changes: 63 additions & 0 deletions app/Jobs/ProcessConversionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
namespace App\Jobs;

use App\Actions\Conversions\RecordConversionResultFileAction;
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;
Expand All @@ -14,6 +17,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;

Expand All @@ -31,6 +35,7 @@ public function __construct(
public function handle(
ConverterDriverRegistry $drivers,
RecordConversionResultFileAction $recorder,
CreditLedger $creditLedger,
): void {
$job = ConversionJob::find($this->conversionJobId);

Expand Down Expand Up @@ -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,
Expand All @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
}

private function markChargeFailed(ConversionJob $job): void
{
$charge = $job->creditCharge;

if ($charge === null) {
return;
}

$charge->forceFill([
'status' => ConversionCreditChargeStatus::Failed,
])->save();
}
}
24 changes: 24 additions & 0 deletions app/Livewire/Dashboard/DashboardConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
}
Comment on lines +198 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't swallow every estimation failure and continue to settings.

Line 204 catches all Throwable, clears the estimate, and still moves the user to settings. If pricing is unsupported or the config is broken, the UI presents a normal conversion flow and only fails later with a generic error or ? credits. Catch the expected billing exception(s) here and surface a targeted state instead; let unexpected failures bubble. As per coding guidelines, "Controllers and Livewire components must stay thin - business logic goes into app/Actions".

Proposed fix
+use App\Exceptions\Billing\UnsupportedConversionCostException;
+
         try {
             $cost = app(EstimateConversionCostAction::class)->handle(
                 $this->currentFile,
                 $converter,
                 $this->options,
             );
             $this->estimatedCreditCost = $cost->amount;
-        } catch (Throwable) {
+        } catch (UnsupportedConversionCostException) {
             $this->estimatedCreditCost = null;
+            $this->targetFormatError = 'We could not estimate credits for this conversion yet.';
+            $this->step = 'format';
+
+            return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
$cost = app(EstimateConversionCostAction::class)->handle(
$this->currentFile,
$converter,
$this->options,
);
$this->estimatedCreditCost = $cost->amount;
} catch (Throwable) {
$this->estimatedCreditCost = null;
}
use App\Exceptions\Billing\UnsupportedConversionCostException;
try {
$cost = app(EstimateConversionCostAction::class)->handle(
$this->currentFile,
$converter,
$this->options,
);
$this->estimatedCreditCost = $cost->amount;
} catch (UnsupportedConversionCostException) {
$this->estimatedCreditCost = null;
$this->targetFormatError = 'We could not estimate credits for this conversion yet.';
$this->step = 'format';
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Livewire/Dashboard/DashboardConverter.php` around lines 197 - 206,
Replace the broad catch(Throwable) in the DashboardConverter Livewire flow
around the EstimateConversionCostAction::handle call with specific
billing/pricing exceptions (e.g., BillingException, PricingUnsupportedException
or whatever domain exceptions your app defines) so only expected pricing
failures are handled; when caught, set a targeted state (for example set
$this->estimatedCreditCost = null and a new flag like $this->pricingUnsupported
= true or $this->pricingErrorMessage) so the UI can render a proper pricing
error, and allow any other unexpected exceptions to bubble up (rethrow) rather
than being swallowed; keep the rest of the call using $this->currentFile,
$converter and $this->options unchanged.


$this->step = 'settings';
}

Expand Down Expand Up @@ -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.';
Expand Down Expand Up @@ -338,6 +361,7 @@ private function resetTargetSelection(): void
$this->optionsSchema = [];
$this->options = [];
$this->optionsByTarget = [];
$this->estimatedCreditCost = null;
}

public function getCurrentJobProperty(): ?ConversionJob
Expand Down
Loading
Loading