Skip to content

Release v0.1.15 — Phase 15: Conversion Cost Estimator#205

Merged
menvil merged 44 commits into
mainfrom
release/v0.1.15-phase15-conversion-cost-estimator
Jun 2, 2026
Merged

Release v0.1.15 — Phase 15: Conversion Cost Estimator#205
menvil merged 44 commits into
mainfrom
release/v0.1.15-phase15-conversion-cost-estimator

Conversation

@menvil

@menvil menvil commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Phase 15 — Conversion Cost Estimator (CONV-215–CONV-235)

Summary

This release adds the conversion cost estimation system and credit enforcement to the FileConverter application.

What's new

  • CreditCost & CreditCostBreakdown DTOs — Typed immutable value objects for cost results
  • ConversionCostEstimator contract — Application interface for cost estimation
  • ConfigDrivenConversionCostEstimator — Implementation reading pricing from config/conversion_costs.php
  • conversion_costs config — MVP pricing: image→image = 1 credit, image→PDF = 2 credits
  • EstimateConversionCostAction — Application-layer facade for UI/API use
  • ConversionCreditCharge model — Product-level charge records linking jobs to credit costs
  • Credit enforcement in CreateConversionJobAction — Blocks job creation when balance < cost
  • Credit capture in ProcessConversionJob — Spends credits only after successful conversion
  • No credits spent on failed conversions — Charge marked failed, balance unchanged
  • Real estimated cost in settings step — UI shows actual credit cost before conversion
  • Insufficient credits UI state — Clear error message when balance is too low

Test coverage

  • 425 tests passing
  • No Cashier/Stripe added
  • composer lint passes
  • npm run build passes

🤖 Generated with Claude Code


Summary by cubic

Adds a config‑driven conversion cost estimator with enforced credit checks and post‑success capture. Shows real costs in settings, blocks low‑balance jobs, and hardens billing with idempotent capture and transaction‑safe job/charge creation (CONV-215–CONV-235).

  • New Features

    • Estimator contract with a config implementation in config/conversion_costs.php (image→image = 1 credit, image→PDF = 2 credits).
    • EstimateConversionCostAction for app/UI use.
    • Credit enforcement at job creation; creates a ConversionCreditCharge with breakdown.
    • Credits captured only after successful conversions; failures are marked and never charged. UI shows estimated cost and an “insufficient credits” message.
    • Reliability: isolated, idempotent capture (lockForUpdate + status check); job + charge creation wrapped in a DB transaction; strict validation for missing/non‑numeric pricing rules.
  • Migration

    • Run database migrations to add conversion_credit_charges and the unique conversion_job_id constraint.
    • Review config/conversion_costs.php groups/pricing as needed; uses the existing CreditLedger.

Written for commit 89413c3. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features
    • Credit cost estimation is now displayed when selecting a conversion target format, allowing users to see the cost before proceeding.
    • Conversions are now blocked if the user has insufficient credits, with a clear error message showing the estimated cost required.
    • Credit charges are automatically tracked and captured upon successful conversion completion.

menvil and others added 30 commits June 2, 2026 15:18
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…st-breakdown-dto

CONV-216: Create CreditCostBreakdown DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n-cost-estimator-contract

CONV-217: Create ConversionCostEstimator contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…iven-conversion-cost-estimator-skeleton

CONV-218: Create ConfigDrivenConversionCostEstimator skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…osts-config

CONV-219: Add conversion costs config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…age-cost

CONV-220: Test image to image cost
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…to-image-cost

CONV-221: Implement image to image cost
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…to-pdf-cost

CONV-223: Implement image to PDF cost
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…own-shape

CONV-224: Test cost breakdown shape
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…reakdown

CONV-225: Implement cost breakdown
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-cost-estimation-is-rejected

CONV-226: Test unsupported cost estimation is rejected
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…conversion-cost-action

CONV-227: Create EstimateConversionCostAction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n-credit-charge-model-and-migration

CONV-228: Create ConversionCreditCharge model and migration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
menvil and others added 13 commits June 2, 2026 15:32
…job-checks-credits-before-queue

CONV-229: Test conversion job checks credits before queue
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in-create-conversion-job-action

CONV-230: Enforce credits in CreateConversionJobAction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tured-after-successful-conversion

CONV-231: Test credits captured after successful conversion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…on-successful-conversion

CONV-232: Capture credits on successful conversion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ersion-does-not-spend-credits

CONV-233: Test failed conversion does not spend credits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ted-cost-in-settings-step

CONV-234: Show real estimated cost in settings step
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-credits-ui-state

CONV-235: Add insufficient credits UI state
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements a complete conversion credit enforcement system. It adds cost estimation contracts and configuration-driven pricing rules, enforces credit availability before job creation, captures credits on successful conversion, and handles charge failures when jobs fail. The UI shows estimated costs and surfaces insufficient-credit errors. All layers integrate with existing job creation/processing pipelines and CreditLedger infrastructure.

Changes

Conversion Credit Enforcement System

Layer / File(s) Summary
Billing contracts and value objects
app/Contracts/Billing/ConversionCostEstimator.php, app/Data/Credits/CreditCost.php, app/Data/Credits/CreditCostBreakdown.php, app/Enums/ConversionCreditChargeStatus.php, app/Exceptions/Billing/UnsupportedConversionCostException.php, tests/Unit/Contracts/*, tests/Unit/Data/*
New interface, enum, and immutable DTOs define conversion cost structure (amount, breakdown with base/size/features), charge lifecycle states (Estimated/Captured/Refunded/Failed), and unsupported conversion exceptions.
Cost estimation service and configuration
app/Actions/Conversions/EstimateConversionCostAction.php, app/Services/Billing/ConfigDrivenConversionCostEstimator.php, config/conversion_costs.php, tests/Feature/Actions/EstimateConversionCostActionTest.php, tests/Unit/Services/*, tests/Unit/Config/*
Configuration file defines cost rules for image→image and image→pdf conversions; ConfigDrivenConversionCostEstimator routes based on format grouping and builds detailed breakdown with rule, converter, file size; EstimateConversionCostAction delegates to estimator.
Job creation with credit validation
app/Actions/Conversions/CreateConversionJobAction.php, tests/Feature/Actions/CreateConversionJobActionCreditTest.php
Constructor receives EstimateConversionCostAction and CreditLedger; handle() estimates cost, checks user balance, throws InsufficientCreditsException if insufficient, and creates ConversionCreditCharge record before dispatching ProcessConversionJob.
Job processing with credit capture
app/Jobs/ProcessConversionJob.php, tests/Feature/Jobs/ProcessConversionJobCreditTest.php
Receives CreditLedger dependency; on success, calls captureCredits() to record spend in transaction and mark charge Captured; on exception, calls markChargeFailed() to set charge status Failed without spending.
Database models and migration
app/Models/ConversionCreditCharge.php, app/Models/ConversionJob.php, database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php, tests/Feature/Models/ConversionCreditChargeTest.php
ConversionCreditCharge model with user/job relationships, status cast, breakdown JSON; ConversionJob adds creditCharge() HasOne; migration creates charges table with foreign keys, amounts, status, breakdown JSON, and indexes.
Factories and service container wiring
database/factories/ConversionCreditChargeFactory.php, database/factories/ConversionJobFactory.php, database/factories/FileRecordFactory.php, app/Providers/AppServiceProvider.php
ConversionCreditChargeFactory with estimated/captured/failed states; FileRecordFactory adds png()/jpg() states; ConversionJobFactory adds pngToJpg() state; AppServiceProvider binds ConversionCostEstimator to ConfigDrivenConversionCostEstimator.
Livewire UI and Blade template
app/Livewire/Dashboard/DashboardConverter.php, resources/views/livewire/dashboard/dashboard-converter.blade.php, tests/Feature/Livewire/DashboardConverterCostTest.php, tests/Feature/Livewire/DashboardConverterSettingsStepTest.php
DashboardConverter adds estimatedCreditCost property, computes estimate in selectTargetFormat() via EstimateConversionCostAction, catches InsufficientCreditsException in convert() with user error message; Blade conditionally displays estimated amount and insufficient-credit error callout.
Integration tests and test fakes
tests/Feature/Conversion/ProcessConversionJobRealDriverTest.php, tests/Feature/ConversionFlowTest.php, tests/Feature/Jobs/ProcessConversionJobTest.php, tests/Fakes/Converters/FakeUnsupportedConverter.php
Real driver, flow, and job tests updated to inject CreditLedger into ProcessConversionJob::handle(); FakeUnsupportedConverter test fake throws exception in cost estimation tests.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • menvil/FileConverter#183: Provides the CreditLedger contract and implementation that this PR injects to check user balance and record captured/refunded charges.
  • menvil/FileConverter#123: Core conversion job creation and processing logic that this PR extends to add credit enforcement and charge state transitions.
  • menvil/FileConverter#103: Implements the DashboardConverter target-format selection handler that this PR extends to estimate and display credit costs.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.20% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the main change: implementing a conversion cost estimator system (Phase 15) as part of version 0.1.15 release.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/v0.1.15-phase15-conversion-cost-estimator

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added the release Triggers AI code review (CodeRabbit, Cubic) label Jun 2, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

4 issues found across 35 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="app/Actions/Conversions/CreateConversionJobAction.php">

<violation number="1" location="app/Actions/Conversions/CreateConversionJobAction.php:61">
P2: Credit enforcement uses a non-atomic balance pre-check, so concurrent requests can over-queue jobs and fail later during capture.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/Jobs/ProcessConversionJob.php Outdated
Comment thread app/Models/ConversionCreditCharge.php
Comment thread app/Actions/Conversions/CreateConversionJobAction.php Outdated

$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>

@menvil

menvil commented Jun 2, 2026

Copy link
Copy Markdown
Owner Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/Actions/Conversions/CreateConversionJobAction.php (1)

57-92: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make credit admission atomic with job creation.

balance() is only a read, so two concurrent requests can both pass the check and both create jobs against the same credits. The same gap also leaves a persisted ConversionJob behind if the charge insert fails after Line 68. Reserve/spend the estimated credits and create the job/charge inside one transaction, then enqueue only after the write commits.

🤖 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/Actions/Conversions/CreateConversionJobAction.php` around lines 57 - 92,
Balance check and job creation are not atomic: calls to
$this->estimateCost->handle and $this->creditLedger->balance allow race
conditions and can leave a ConversionJob if the ConversionCreditCharge insert
fails. Wrap the creation of ConversionJob and ConversionCreditCharge plus the
credit reservation call in a single DB transaction, and perform the credit
reservation/spend via the credit ledger API (use the ledger's reserve/debit
method rather than separate read) inside that transaction; only call
ProcessConversionJob::dispatch($job->id) after the transaction has committed
(e.g., dispatch in an after-commit callback) so the enqueue happens only when
job and charge are persisted.
app/Jobs/ProcessConversionJob.php (1)

65-72: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't persist Completed before the debit succeeds.

If the worker dies after Line 70 but before captureCredits() commits, a retry returns at Line 41 because the job is no longer Queued, so the conversion stays completed with no credit spend. The same ordering also means a transient ledger failure turns an already-recorded result into Failed. Keep the terminal job state change and charge capture in the same transaction, and only write Completed after the debit succeeds.

Also applies to: 87-110

🤖 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/Jobs/ProcessConversionJob.php` around lines 65 - 72, The job persists
status = ConversionStatus::Completed before charging credits, which can leave a
job marked completed if the debit fails; change the ordering and wrap the
charge+terminal state update in a single DB transaction so they commit
atomically: inside a transaction (use DB::transaction or equivalent) call
captureCredits($job, $creditLedger) first (so debit succeeds) then forceFill the
terminal fields ('result_file_id', 'status' => ConversionStatus::Completed,
'progress' => 100, 'completed_at' => now()) and save the $job; apply the same
transactional pattern to the other terminal-update block referenced around the
87-110 region to ensure the ledger debit and final job state are saved together.
🧹 Nitpick comments (2)
tests/Unit/Contracts/ConversionCostEstimatorContractTest.php (1)

7-9: ⚡ Quick win

This test never exercises contract resolution.

interface_exists() only proves the interface file is autoloadable. It will still pass if the container binding is missing or incorrect, so it does not cover what the test name and PR behavior promise. Resolve ConversionCostEstimator::class from the container and assert the concrete service instead.

Suggested rewrite
+use App\Services\Billing\ConfigDrivenConversionCostEstimator;
+
-it('can resolve conversion cost estimator contract', function () {
-    expect(interface_exists(ConversionCostEstimator::class))->toBeTrue();
+it('resolves the conversion cost estimator contract from the container', function () {
+    $estimator = app(ConversionCostEstimator::class);
+
+    expect($estimator)->toBeInstanceOf(ConfigDrivenConversionCostEstimator::class);
 });
🤖 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 `@tests/Unit/Contracts/ConversionCostEstimatorContractTest.php` around lines 7
- 9, The test currently only calls
interface_exists(ConversionCostEstimator::class) which only checks autoloading;
instead resolve the contract from the container in the "can resolve conversion
cost estimator contract" test (use app()->make(ConversionCostEstimator::class)
or resolve(ConversionCostEstimator::class)) and assert the resolved value is an
object implementing the ConversionCostEstimator contract (e.g.
assertInstanceOf(ConversionCostEstimator::class, $service)); optionally also
assert it is the expected concrete class if you have a known implementation
class to validate.
tests/Feature/Actions/CreateConversionJobActionCreditTest.php (1)

17-18: ⚡ Quick win

Seed balances through CreditLedger instead of forceFill().

These tests bypass the same abstraction production uses to read balances. If the ledger stops treating the account row as source of truth, the tests will keep passing while exercising a different path. Prefer a ledger helper or factory state that drives CreditLedger semantics for the insufficient-credit setup too.

Also applies to: 33-34

🤖 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 `@tests/Feature/Actions/CreateConversionJobActionCreditTest.php` around lines
17 - 18, The test currently sets a user's balance by calling
User::factory()->create() then mutating
$user->creditAccount->forceFill(['balance' => 1])->save(); instead seed the
balance via the same ledger abstraction used in production: create CreditLedger
entries (or use a test helper/factory state that writes to CreditLedger) for the
user to produce the desired insufficient balance instead of forceFill on
creditAccount; update both occurrences in CreateConversionJobActionCreditTest
(and the similar block at lines 33-34) to use the CreditLedger-writing helper or
factory state so tests exercise the same read/write semantics as production.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@app/Data/Credits/CreditCostBreakdown.php`:
- Around line 9-28: The constructor of CreditCostBreakdown currently allows
inconsistent data (base, size, features vs total); update
CreditCostBreakdown::__construct to enforce consistency by either (a) computing
$total = $base + $size + $features and assigning that value instead of accepting
an external total, or (b) validating that $total === ($base + $size + $features)
and throwing an \InvalidArgumentException on mismatch; implement the chosen
approach consistently and keep the existing negative-value checks for base,
size, features, and total.

In `@app/Jobs/ProcessConversionJob.php`:
- Around line 95-109: Inside the DB::transaction where you call
$creditLedger->spend(...) and update $charge, reload and lock the charge row
first (e.g., re-query the Charge model with lockForUpdate()) and check its
status; if the current status is not ConversionCreditChargeStatus::Estimated,
abort the transaction (do not call spend() or update captured_amount/status).
This ensures spend() is only called once and makes the capture idempotent under
duplicate execution.

In `@app/Livewire/Dashboard/DashboardConverter.php`:
- Around line 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.

In `@app/Models/ConversionCreditCharge.php`:
- Around line 28-31: The model ConversionCreditCharge currently only casts
'status' and 'breakdown_json', causing numeric DB columns to be hydrated as
strings and potentially raising a TypeError when
ProcessConversionJob::captureCredits passes $charge->estimated_amount into
CreditLedger::spend(int $amount) or assigns to captured_amount/refunded_amount.
Update the ConversionCreditCharge::$casts to include 'estimated_amount',
'captured_amount', and 'refunded_amount' as integers so these attributes are
strictly typed to int at retrieval and assignment.

In `@app/Services/Billing/ConfigDrivenConversionCostEstimator.php`:
- Line 37: The code in ConfigDrivenConversionCostEstimator.php silently defaults
$base to 1 when config("conversion_costs.rules.{$rule}.base") is missing or
malformed; change it to validate the setting and fail fast: in the method where
$base is assigned (the conversion cost estimation method using $rule and $base),
first check that the config key exists and is a valid integer/number (e.g.,
config()->has("conversion_costs.rules.{$rule}.base") and is_numeric on the raw
value), and if not throw a clear exception (InvalidArgumentException or a
domain-specific exception) indicating the missing/invalid pricing rule instead
of casting to 1. Ensure the thrown error includes the rule name to aid
debugging.

In
`@database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php`:
- Around line 14-23: The migration creates a non-unique index on
conversion_job_id but ConversionJob::creditCharge() expects a one-to-one
relation; change the schema so conversion_job_id is unique (either add
->unique() on the conversion_job_id column or replace the index with a unique
constraint) so only one charge row can reference a given conversion_job_id;
update the migration that defines the conversion_job_id column (and remove the
plain ->index('conversion_job_id')) to enforce uniqueness.

---

Outside diff comments:
In `@app/Actions/Conversions/CreateConversionJobAction.php`:
- Around line 57-92: Balance check and job creation are not atomic: calls to
$this->estimateCost->handle and $this->creditLedger->balance allow race
conditions and can leave a ConversionJob if the ConversionCreditCharge insert
fails. Wrap the creation of ConversionJob and ConversionCreditCharge plus the
credit reservation call in a single DB transaction, and perform the credit
reservation/spend via the credit ledger API (use the ledger's reserve/debit
method rather than separate read) inside that transaction; only call
ProcessConversionJob::dispatch($job->id) after the transaction has committed
(e.g., dispatch in an after-commit callback) so the enqueue happens only when
job and charge are persisted.

In `@app/Jobs/ProcessConversionJob.php`:
- Around line 65-72: The job persists status = ConversionStatus::Completed
before charging credits, which can leave a job marked completed if the debit
fails; change the ordering and wrap the charge+terminal state update in a single
DB transaction so they commit atomically: inside a transaction (use
DB::transaction or equivalent) call captureCredits($job, $creditLedger) first
(so debit succeeds) then forceFill the terminal fields ('result_file_id',
'status' => ConversionStatus::Completed, 'progress' => 100, 'completed_at' =>
now()) and save the $job; apply the same transactional pattern to the other
terminal-update block referenced around the 87-110 region to ensure the ledger
debit and final job state are saved together.

---

Nitpick comments:
In `@tests/Feature/Actions/CreateConversionJobActionCreditTest.php`:
- Around line 17-18: The test currently sets a user's balance by calling
User::factory()->create() then mutating
$user->creditAccount->forceFill(['balance' => 1])->save(); instead seed the
balance via the same ledger abstraction used in production: create CreditLedger
entries (or use a test helper/factory state that writes to CreditLedger) for the
user to produce the desired insufficient balance instead of forceFill on
creditAccount; update both occurrences in CreateConversionJobActionCreditTest
(and the similar block at lines 33-34) to use the CreditLedger-writing helper or
factory state so tests exercise the same read/write semantics as production.

In `@tests/Unit/Contracts/ConversionCostEstimatorContractTest.php`:
- Around line 7-9: The test currently only calls
interface_exists(ConversionCostEstimator::class) which only checks autoloading;
instead resolve the contract from the container in the "can resolve conversion
cost estimator contract" test (use app()->make(ConversionCostEstimator::class)
or resolve(ConversionCostEstimator::class)) and assert the resolved value is an
object implementing the ConversionCostEstimator contract (e.g.
assertInstanceOf(ConversionCostEstimator::class, $service)); optionally also
assert it is the expected concrete class if you have a known implementation
class to validate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8a99d59d-070e-411b-8139-e2f610422855

📥 Commits

Reviewing files that changed from the base of the PR and between 646203d and 9d5f474.

📒 Files selected for processing (35)
  • app/Actions/Conversions/CreateConversionJobAction.php
  • app/Actions/Conversions/EstimateConversionCostAction.php
  • app/Contracts/Billing/ConversionCostEstimator.php
  • app/Data/Credits/CreditCost.php
  • app/Data/Credits/CreditCostBreakdown.php
  • app/Enums/ConversionCreditChargeStatus.php
  • app/Exceptions/Billing/UnsupportedConversionCostException.php
  • app/Jobs/ProcessConversionJob.php
  • app/Livewire/Dashboard/DashboardConverter.php
  • app/Models/ConversionCreditCharge.php
  • app/Models/ConversionJob.php
  • app/Providers/AppServiceProvider.php
  • app/Services/Billing/ConfigDrivenConversionCostEstimator.php
  • config/conversion_costs.php
  • database/factories/ConversionCreditChargeFactory.php
  • database/factories/ConversionJobFactory.php
  • database/factories/FileRecordFactory.php
  • database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php
  • resources/views/livewire/dashboard/dashboard-converter.blade.php
  • tests/Fakes/Converters/FakeUnsupportedConverter.php
  • tests/Feature/Actions/CreateConversionJobActionCreditTest.php
  • tests/Feature/Actions/EstimateConversionCostActionTest.php
  • tests/Feature/Conversion/ProcessConversionJobRealDriverTest.php
  • tests/Feature/ConversionFlowTest.php
  • tests/Feature/Jobs/ProcessConversionJobCreditTest.php
  • tests/Feature/Jobs/ProcessConversionJobTest.php
  • tests/Feature/Livewire/DashboardConverterCostTest.php
  • tests/Feature/Livewire/DashboardConverterSettingsStepTest.php
  • tests/Feature/Models/ConversionCreditChargeTest.php
  • tests/Unit/Config/ConversionCostsConfigTest.php
  • tests/Unit/Contracts/ConversionCostEstimatorContractTest.php
  • tests/Unit/Data/CreditCostBreakdownTest.php
  • tests/Unit/Data/CreditCostTest.php
  • tests/Unit/Services/ConversionCostEstimatorResolutionTest.php
  • tests/Unit/Services/ConversionCostEstimatorTest.php

Comment thread app/Data/Credits/CreditCostBreakdown.php
Comment thread app/Jobs/ProcessConversionJob.php
Comment on lines +197 to +206
try {
$cost = app(EstimateConversionCostAction::class)->handle(
$this->currentFile,
$converter,
$this->options,
);
$this->estimatedCreditCost = $cost->amount;
} catch (Throwable) {
$this->estimatedCreditCost = null;
}

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.

Comment thread app/Models/ConversionCreditCharge.php
Comment thread app/Services/Billing/ConfigDrivenConversionCostEstimator.php Outdated
Comment on lines +14 to +23
$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');

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

Enforce one charge row per conversion job.

ConversionJob::creditCharge() is modeled as HasOne, but this table only adds a non-unique index on conversion_job_id. That lets multiple charge rows point at the same job, so capture/failure reads can pick the wrong record and drift billing state. Make conversion_job_id unique instead of plain indexed.

Suggested schema fix
-            $table->foreignId('conversion_job_id')->nullable()->constrained()->nullOnDelete();
+            $table->foreignId('conversion_job_id')->nullable()->unique()->constrained()->nullOnDelete();
...
-            $table->index('conversion_job_id');
📝 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
$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');
$table->foreignId('conversion_job_id')->nullable()->unique()->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']);
🤖 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
`@database/migrations/2026_06_02_123052_create_conversion_credit_charges_table.php`
around lines 14 - 23, The migration creates a non-unique index on
conversion_job_id but ConversionJob::creditCharge() expects a one-to-one
relation; change the schema so conversion_job_id is unique (either add
->unique() on the conversion_job_id column or replace the index with a unique
constraint) so only one charge row can reference a given conversion_job_id;
update the migration that defines the conversion_job_id column (and remove the
plain ->index('conversion_job_id')) to enforce uniqueness.

- 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 <noreply@anthropic.com>
@menvil menvil merged commit 7059bea into main Jun 2, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release Triggers AI code review (CodeRabbit, Cubic)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant