Skip to content

Release v0.1.16 — Phase 16: Laravel Cashier Foundation#223

Merged
menvil merged 42 commits into
mainfrom
release/v0.1.16-phase16-laravel-cashier-foundation
Jun 2, 2026
Merged

Release v0.1.16 — Phase 16: Laravel Cashier Foundation#223
menvil merged 42 commits into
mainfrom
release/v0.1.16-phase16-laravel-cashier-foundation

Conversation

@menvil

@menvil menvil commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Phase 16 — Laravel Cashier Foundation

Completes CONV-236 → CONV-256.

What's in this release

Cashier Foundation (CONV-236–238)

  • Laravel Cashier v16.5.3 installed
  • Cashier migrations published (customer columns, subscriptions, subscription items)
  • User model uses Billable trait
  • Stripe/Cashier env vars documented in .env.example and phpunit.xml (test placeholders)

Billing Domain (CONV-239–242)

  • App\Enums\Plan reused (no duplicate created)
  • config/billing_plans.php — free (50cr), pro (1000cr), max (5000cr) with Stripe price ID env vars
  • BillingPlanDto — readonly DTO
  • BillingPlanRepository — typed access to plans config, throws UnknownBillingPlanException

Payment Service (CONV-243–246)

  • SubscriptionCheckoutGateway interface + CashierSubscriptionCheckoutGateway (prod) + FakeSubscriptionCheckoutGateway (tests)
  • BillingPaymentService — creates subscription checkout, rejects free plan
  • POST /billing/checkout/{plan} route — auth-protected, delegates to service
  • /billing/success and /billing/cancel pages — auth-protected

Webhooks (CONV-247–256)

  • /stripe/webhook auto-registered by Cashier
  • billing_webhook_events table with unique provider_event_id
  • BillingWebhookEventRecorder — idempotent event recording
  • SubscriptionWebhookHandler:
    • handleSubscriptionActivated → updates user.plan
    • handleSubscriptionCancelled → downgrades to free (MVP policy)
    • handleInvoicePaid → grants monthly credits via CreditLedger, idempotent by invoice ID

What's NOT in this release

  • Credit packs, one-time checkout, full billing UI, invoice history, Stripe Customer Portal, coupons, trials, API billing

Test plan

  • composer test passes (459 tests)
  • composer lint passes
  • npm run build passes
  • php artisan migrate:fresh --seed passes

🤖 Generated with Claude Code


Summary by cubic

Sets up the billing foundation with laravel/cashier and Stripe subscriptions: plan config, checkout, and robust webhooks that update user plans and grant monthly credits with safer idempotency. Completes CONV-236→CONV-256.

  • New Features

    • Plans config and typed access (free/pro/max) with monthly credits; free plan checkout is blocked and blank price IDs are rejected.
    • Subscription checkout via Stripe; POST /billing/checkout/{plan} for signed-in users; success and cancel pages.
    • Webhooks: Cashier’s /stripe/webhook; idempotent event recorder with composite (provider, provider_event_id) key; invoice grants wrapped in a DB transaction with row lock and firstOrCreate on the credit account to prevent duplicates; portable JSON lookup for invoice ID; activation sets plan, cancel downgrades to Plan::Free.
    • User model now uses Cashier Billable.
  • Migration

    • Run database migrations to add Cashier tables, user Stripe columns, and billing_webhook_events with a composite unique index.
    • Set env vars: STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, CASHIER_CURRENCY, STRIPE_PRO_PRICE_ID, STRIPE_MAX_PRICE_ID.
    • In Stripe, configure the webhook to POST to /stripe/webhook with Cashier’s default events.

Written for commit 10301af. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added subscription billing system with Stripe integration
    • Introduced tiered billing plans (free, pro, max) with varying monthly credit allocations
    • Added subscription checkout flow for plan upgrades
    • Added automatic monthly credit grants for paid subscriptions
    • Added billing confirmation pages for checkout success and cancellation

menvil and others added 30 commits June 2, 2026 17:30
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…cashier-stripe

CONV-236: Install Laravel Cashier Stripe
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…migrations-and-add-billable-trait

CONV-237: Publish Cashier migrations and add Billable trait
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-environment

CONV-238: Configure Stripe environment
Reuses existing App\Enums\Plan (free/pro/max) — no duplicate created.
Adds test verifying free/pro/max plan values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lan-enum

CONV-239: Create BillingPlan enum
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lans-config

CONV-240: Create billing plans config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lan-dto

CONV-241: Create BillingPlan DTO
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lan-repository

CONV-242: Create BillingPlanRepository
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ment-service-creates-subscription-checkout

CONV-243/244: Test and implement BillingPaymentService subscription checkout
Also adds billing success/cancel routes and minimal views
(CONV-246 scope pre-included to support route() resolution in controller).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-checkout-route

CONV-245: Add subscription checkout route
Routes and views were pre-added in CONV-245 to support route()
resolution. This task adds the feature tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ess-and-cancel-pages

CONV-246: Add billing success and cancel pages
Cashier auto-registers /stripe/webhook — smoke test confirms route exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r-webhook-route

CONV-247: Configure Cashier webhook route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ebhook-events-table

CONV-248: Create billing webhook events table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ebhook-event-recorder

CONV-249: Create BillingWebhookEventRecorder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion-webhook-handler-skeleton

CONV-250: Create SubscriptionWebhookHandler skeleton
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
menvil and others added 8 commits June 2, 2026 17:48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n-activated-updates-user-plan

CONV-251/252: Test and implement subscription activated handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n-cancelled-downgrades-user

CONV-253/254: Test and implement subscription cancelled handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…e-grants-monthly-credits-once

CONV-255/256: Test and implement monthly subscription credit grant
@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds end-to-end subscription billing using Laravel Cashier and Stripe. It introduces billing plan management via DTOs and a config-driven repository, a gateway abstraction for checkout creation, webhook event persistence with idempotency, subscription plan updates triggered by Stripe webhooks, and monthly credit grants locked per-user to serialize concurrent invoice processing.

Changes

Billing and Subscription Integration

Layer / File(s) Summary
Billing Plans: DTO, Repository, and Configuration
app/Billing/BillingPlanDto.php, app/Billing/BillingPlanRepository.php, config/billing_plans.php, tests/Unit/Billing/*PlanDtoTest.php, tests/Unit/Billing/*PlanRepositoryTest.php, tests/Unit/Billing/*PlansConfigTest.php
Three billing plan variants (free, pro, max) are defined in config with labels, Stripe price IDs, and monthly credit amounts. BillingPlanDto wraps plan data, and BillingPlanRepository reads config and provides filtered access to paid plans, throwing UnknownBillingPlanException for missing keys.
Subscription Checkout Gateway Abstraction
app/Billing/Gateway/SubscriptionCheckoutGateway.php, app/Billing/Gateway/CashierSubscriptionCheckoutGateway.php, app/Billing/Gateway/FakeSubscriptionCheckoutGateway.php, app/Billing/CheckoutSessionDto.php
The SubscriptionCheckoutGateway interface abstracts checkout session creation. CashierSubscriptionCheckoutGateway delegates to Cashier's checkout builder with success/cancel URLs; FakeSubscriptionCheckoutGateway returns a fixed test URL. CheckoutSessionDto wraps the resulting checkout URL.
Payment Service and Checkout Validation
app/Billing/BillingPaymentService.php, app/Billing/Exceptions/CannotCheckoutFreePlanException.php, app/Billing/Exceptions/UnknownBillingPlanException.php
BillingPaymentService validates that plans are paid and have non-null Stripe price IDs before delegating to the gateway. Throws CannotCheckoutFreePlanException for free plans and UnknownBillingPlanException for missing plans.
HTTP Checkout Initiation and Routes
app/Http/Controllers/Billing/StartSubscriptionCheckoutController.php, routes/web.php
Invokable controller loads a billing plan, calls BillingPaymentService to create a checkout, and redirects to the Stripe session URL; returns HTTP 404 for unknown plans and HTTP 422 for free plans. Routes register POST /billing/checkout/{plan} and GET /billing/success and /billing/cancel pages.
Webhook Event Model and Persistence
app/Models/BillingWebhookEvent.php, app/Billing/Webhooks/BillingWebhookEventRecorder.php, database/factories/BillingWebhookEventFactory.php, tests/Feature/Billing/*WebhookEventTest.php, tests/Feature/Billing/*WebhookEventRecorderTest.php
BillingWebhookEvent Eloquent model stores provider, event ID, type, payload, and processing status. BillingWebhookEventRecorder provides idempotent creation via (provider, provider_event_id) uniqueness, marks events processed by updating processed_at, and checks processing state. Tests verify idempotency and unique constraint enforcement.
Subscription Webhook Handler and Credit Granting
app/Billing/Webhooks/SubscriptionWebhookHandler.php, tests/Feature/Billing/SubscriptionWebhookHandlerTest.php, tests/Feature/Billing/SubscriptionMonthlyCreditGrantTest.php
Handles subscription lifecycle: handleSubscriptionActivated updates user plan, handleSubscriptionCancelled downgrades to free, and handleInvoicePaid grants monthly credits via transaction lock (preventing duplicate grants per invoice ID). Tests validate plan updates, exception handling on unknown plans, and credit idempotency.
Database Migrations for Cashier Support
database/migrations/2026_06_02_143126_create_customer_columns.php, database/migrations/2026_06_02_143127_create_subscriptions_table.php, database/migrations/2026_06_02_143128_create_subscription_items_table.php, database/migrations/2026_06_02_143129_add_meter_id_to_subscription_items_table.php, database/migrations/2026_06_02_143130_add_meter_event_name_to_subscription_items_table.php, database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php, database/migrations/2026_06_02_151136_update_billing_webhook_events_unique_to_composite.php
Adds nullable Stripe columns to users table, creates subscriptions and subscription_items tables with Stripe references, meter tracking columns, and billing_webhook_events table with composite (provider, provider_event_id) uniqueness for idempotency.
User Model Integration and Service Container Binding
app/Models/User.php, app/Providers/AppServiceProvider.php, composer.json
User model declares the Billable trait from Laravel Cashier. AppServiceProvider binds SubscriptionCheckoutGateway::class to CashierSubscriptionCheckoutGateway for production. composer.json adds laravel/cashier ^16.5 dependency.
Cashier Configuration and Environment Setup
config/cashier.php, .env.example, phpunit.xml
Full Cashier config with environment-driven Stripe keys/secrets, webhook handling, currency defaults, invoice rendering options, and logging. .env.example and phpunit.xml define placeholders for Stripe credentials and price IDs.
Checkout Outcome Pages and Routes
resources/views/billing/success.blade.php, resources/views/billing/cancel.blade.php
Blade templates for checkout success and cancellation outcomes, wrapping results in app layout with navigation back to dashboard.
Feature Tests for Checkout and Webhook Flows
tests/Feature/Billing/BillingPaymentServiceTest.php, tests/Feature/Billing/BillingResultPagesTest.php, tests/Feature/Billing/CashierWebhookRouteTest.php, tests/Feature/Billing/SubscriptionCheckoutRouteTest.php
Tests validate payment service behavior (paid vs. free plans), result page access (authenticated vs. unauthenticated), webhook endpoint registration, and route-level validation (plan resolution, HTTP status codes).
Unit Tests for Billing Components
tests/Unit/Billing/BillingPlanDtoTest.php, tests/Unit/Billing/CashierBillableTest.php, tests/Unit/Billing/CashierConfigTest.php, tests/Unit/Billing/SubscriptionWebhookHandlerSkeletonTest.php
Unit tests verify plan DTOs from config, User model Cashier trait integration, Cashier config presence, and webhook handler container resolution.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • menvil/FileConverter#183: Introduces the CreditLedger contract and CreditTransaction database model that SubscriptionWebhookHandler uses to grant monthly subscription credits.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.39% 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 summarizes the main change: implementing Phase 16 of a billing system using Laravel Cashier, a well-known Laravel payment framework.
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.16-phase16-laravel-cashier-foundation

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.

6 issues found across 46 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="database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php">

<violation number="1" location="database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php:17">
P2: Webhook idempotency is keyed globally by event ID, ignoring provider, which can cause cross-provider event collisions.</violation>
</file>

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

Re-trigger cubic

Comment thread app/Billing/Webhooks/SubscriptionWebhookHandler.php Outdated
Comment thread resources/views/billing/cancel.blade.php Outdated
Comment thread resources/views/billing/success.blade.php Outdated
Comment thread config/billing_plans.php Outdated
Schema::create('billing_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('provider_event_id')->unique();

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: Webhook idempotency is keyed globally by event ID, ignoring provider, which can cause cross-provider event collisions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php, line 17:

<comment>Webhook idempotency is keyed globally by event ID, ignoring provider, which can cause cross-provider event collisions.</comment>

<file context>
@@ -0,0 +1,32 @@
+        Schema::create('billing_webhook_events', function (Blueprint $table) {
+            $table->id();
+            $table->string('provider');
+            $table->string('provider_event_id')->unique();
+            $table->string('type');
+            $table->json('payload')->nullable();
</file context>

Comment thread app/Billing/BillingPaymentService.php Outdated
menvil and others added 2 commits June 2, 2026 18:13
- P1: Wrap handleInvoicePaid in DB::transaction + lockForUpdate to
  prevent race condition on concurrent invoice webhook delivery
- P2: billing_webhook_events unique constraint changed to composite
  (provider, provider_event_id) to prevent cross-provider collisions;
  BillingWebhookEventRecorder updated to use composite key
- P2: billing/success and billing/cancel views now extend x-layouts.app
  with navigation and app styling
- P2: Removed string fallbacks from billing_plans.php config;
  test price IDs moved to phpunit.xml
- P2: BillingPaymentService guards against empty-string Stripe price IDs
  using blank() instead of === null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@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: 3

🧹 Nitpick comments (3)
app/Billing/Webhooks/SubscriptionWebhookHandler.php (1)

26-29: 💤 Low value

Replace hardcoded 'free' with App\Enums\Plan::Free in cancellation.

SubscriptionWebhookHandler::handleSubscriptionCancelled() hardcodes ['plan' => 'free'], but User::$casts['plan'] is Plan::class and App\Enums\Plan defines case Free = 'free'. Set ['plan' => Plan::Free->value] (or Plan::Free) instead.

🤖 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/Billing/Webhooks/SubscriptionWebhookHandler.php` around lines 26 - 29,
SubscriptionWebhookHandler::handleSubscriptionCancelled currently force-fills
the user's plan with the hardcoded string 'free', which conflicts with
User::$casts['plan'] expecting an App\Enums\Plan; update the assignment in
handleSubscriptionCancelled to use the enum instead (App\Enums\Plan::Free or
App\Enums\Plan::Free->value as appropriate for your cast) so the plan is set via
the Plan enum rather than the literal string.
database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php (1)

19-19: Consider PII retention for the raw payload.

Stripe webhook payloads (e.g. invoice.paid, customer.subscription.*) typically embed customer PII such as email, name, and billing address. Persisting the full payload indefinitely in this JSON column may create GDPR/CCPA retention obligations. Consider a retention/pruning policy or storing only the fields you actually need for idempotent processing.

🤖 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_144415_create_billing_webhook_events_table.php`
at line 19, The migration currently persists the full Stripe webhook JSON via
$table->json('payload')->nullable(); which may retain PII indefinitely; change
the schema and storage approach to avoid raw payload retention by either (a)
replacing or supplementing the payload column with a minimized JSON or specific
columns containing only required fields (e.g. event_id, type, customer_id,
invoice_id) and remove sensitive fields, or (b) keep payload but mark it
encrypted and add retention metadata (e.g. received_at timestamp and a
retention_policy flag) and implement a scheduled pruning job to delete or redact
payloads older than your retention window; update the migration
(create_billing_webhook_events_table) and any write paths that populate payload
to produce the minimized/encrypted payload and ensure existing rows are migrated
or backfilled according to the chosen policy.
app/Billing/BillingPlanRepository.php (1)

36-45: ⚡ Quick win

Consider validating required config keys before accessing.

If the billing_plans.plans config is malformed (missing label, stripe_price_id, monthly_credits, or is_paid), this method will throw an undefined array key error. Adding validation would produce clearer error messages during development.

🛡️ Proposed validation enhancement
 private function toDto(string $key, array $data): BillingPlanDto
 {
+    $required = ['label', 'stripe_price_id', 'monthly_credits', 'is_paid'];
+    $missing = array_diff($required, array_keys($data));
+    if (!empty($missing)) {
+        throw new \RuntimeException(
+            "Billing plan '{$key}' is missing required config keys: " . implode(', ', $missing)
+        );
+    }
+
     return new BillingPlanDto(
         key: $key,
         label: $data['label'],
         stripePriceId: $data['stripe_price_id'],
         monthlyCredits: $data['monthly_credits'],
         isPaid: $data['is_paid'],
     );
 }
🤖 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/Billing/BillingPlanRepository.php` around lines 36 - 45, The toDto method
currently accesses array keys directly which will raise undefined array key
errors if config is malformed; update BillingPlanRepository::toDto to validate
that the required keys ('label', 'stripe_price_id', 'monthly_credits',
'is_paid') exist in the $data array before constructing BillingPlanDto, and if
any are missing throw a clear InvalidArgumentException (or domain-specific
exception) naming the missing keys and the $key identifier so callers/developers
get an actionable error message.
🤖 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/Billing/Gateway/CashierSubscriptionCheckoutGateway.php`:
- Around line 17-24: The code currently assumes $checkout->url contains the
Stripe hosted redirect URL; inspect the Checkout object returned by
$user->newSubscription(...)->checkout(...) (symbol: newSubscription and
checkout) and update the logic to reliably extract the URL (e.g., prefer
$checkout->url if present, otherwise fallback to $checkout->toArray()['url'] or
the Checkout wrapper getter) before constructing CheckoutSessionDto; also detect
if Cashier is using ui_mode 'embedded' or 'custom' and, in that case, ensure the
frontend flow uses return_url semantics rather than relying on a hosted redirect
URL so you don't return an invalid URL from CheckoutSessionDto.

In `@app/Billing/Webhooks/SubscriptionWebhookHandler.php`:
- Line 42: In handleInvoicePaid() replace the risky
$user->creditAccount()->lockForUpdate()->firstOrFail() pattern so the webhook
doesn't throw when a CreditAccount row is missing: either use
lockForUpdate()->firstOrCreate([...]) to create the row under the lock, or
remove the pre-lock/lookup and rely on DatabaseCreditLedger::grant() (which
already does firstOrCreate(...)) to provision the account; ensure the code path
calls creditLedger->grant(...) and no firstOrFail remains so missing
CreditAccount rows no longer cause the webhook to fail.
- Around line 61-67: Change alreadyGrantedForInvoice to accept the current User
and scope the query by user_id, and replace the DB-specific whereRaw JSON check
with Laravel's portable JSON operator: update the signature to
alreadyGrantedForInvoice(User $user, string $invoiceId) and inside use
CreditTransaction::query()->where('reason',
'subscription_monthly_grant')->where('user_id',
$user->id)->where('metadata_json->stripe_invoice_id', $invoiceId)->exists();
then update the call site to pass $user (e.g.
$this->alreadyGrantedForInvoice($user, $invoiceId)).

---

Nitpick comments:
In `@app/Billing/BillingPlanRepository.php`:
- Around line 36-45: The toDto method currently accesses array keys directly
which will raise undefined array key errors if config is malformed; update
BillingPlanRepository::toDto to validate that the required keys ('label',
'stripe_price_id', 'monthly_credits', 'is_paid') exist in the $data array before
constructing BillingPlanDto, and if any are missing throw a clear
InvalidArgumentException (or domain-specific exception) naming the missing keys
and the $key identifier so callers/developers get an actionable error message.

In `@app/Billing/Webhooks/SubscriptionWebhookHandler.php`:
- Around line 26-29: SubscriptionWebhookHandler::handleSubscriptionCancelled
currently force-fills the user's plan with the hardcoded string 'free', which
conflicts with User::$casts['plan'] expecting an App\Enums\Plan; update the
assignment in handleSubscriptionCancelled to use the enum instead
(App\Enums\Plan::Free or App\Enums\Plan::Free->value as appropriate for your
cast) so the plan is set via the Plan enum rather than the literal string.

In
`@database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php`:
- Line 19: The migration currently persists the full Stripe webhook JSON via
$table->json('payload')->nullable(); which may retain PII indefinitely; change
the schema and storage approach to avoid raw payload retention by either (a)
replacing or supplementing the payload column with a minimized JSON or specific
columns containing only required fields (e.g. event_id, type, customer_id,
invoice_id) and remove sensitive fields, or (b) keep payload but mark it
encrypted and add retention metadata (e.g. received_at timestamp and a
retention_policy flag) and implement a scheduled pruning job to delete or redact
payloads older than your retention window; update the migration
(create_billing_webhook_events_table) and any write paths that populate payload
to produce the minimized/encrypted payload and ensure existing rows are migrated
or backfilled according to the chosen policy.
🪄 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: 0c5440d4-073d-4d28-9fa1-6bfdd1b4e22f

📥 Commits

Reviewing files that changed from the base of the PR and between 7059bea and d5f5a35.

⛔ Files ignored due to path filters (1)
  • composer.lock is excluded by !**/*.lock
📒 Files selected for processing (46)
  • .env.example
  • app/Billing/BillingPaymentService.php
  • app/Billing/BillingPlanDto.php
  • app/Billing/BillingPlanRepository.php
  • app/Billing/CheckoutSessionDto.php
  • app/Billing/Exceptions/CannotCheckoutFreePlanException.php
  • app/Billing/Exceptions/UnknownBillingPlanException.php
  • app/Billing/Gateway/CashierSubscriptionCheckoutGateway.php
  • app/Billing/Gateway/FakeSubscriptionCheckoutGateway.php
  • app/Billing/Gateway/SubscriptionCheckoutGateway.php
  • app/Billing/Webhooks/BillingWebhookEventRecorder.php
  • app/Billing/Webhooks/SubscriptionWebhookHandler.php
  • app/Http/Controllers/Billing/StartSubscriptionCheckoutController.php
  • app/Models/BillingWebhookEvent.php
  • app/Models/User.php
  • app/Providers/AppServiceProvider.php
  • composer.json
  • config/billing_plans.php
  • config/cashier.php
  • database/factories/BillingWebhookEventFactory.php
  • database/migrations/2026_06_02_143126_create_customer_columns.php
  • database/migrations/2026_06_02_143127_create_subscriptions_table.php
  • database/migrations/2026_06_02_143128_create_subscription_items_table.php
  • database/migrations/2026_06_02_143129_add_meter_id_to_subscription_items_table.php
  • database/migrations/2026_06_02_143130_add_meter_event_name_to_subscription_items_table.php
  • database/migrations/2026_06_02_144415_create_billing_webhook_events_table.php
  • database/migrations/2026_06_02_151136_update_billing_webhook_events_unique_to_composite.php
  • phpunit.xml
  • resources/views/billing/cancel.blade.php
  • resources/views/billing/success.blade.php
  • routes/web.php
  • tests/Feature/Billing/BillingPaymentServiceTest.php
  • tests/Feature/Billing/BillingResultPagesTest.php
  • tests/Feature/Billing/BillingWebhookEventRecorderTest.php
  • tests/Feature/Billing/BillingWebhookEventTest.php
  • tests/Feature/Billing/CashierWebhookRouteTest.php
  • tests/Feature/Billing/SubscriptionCheckoutRouteTest.php
  • tests/Feature/Billing/SubscriptionMonthlyCreditGrantTest.php
  • tests/Feature/Billing/SubscriptionWebhookHandlerTest.php
  • tests/Unit/Billing/BillingPlanDtoTest.php
  • tests/Unit/Billing/BillingPlanRepositoryTest.php
  • tests/Unit/Billing/BillingPlanTest.php
  • tests/Unit/Billing/BillingPlansConfigTest.php
  • tests/Unit/Billing/CashierBillableTest.php
  • tests/Unit/Billing/CashierConfigTest.php
  • tests/Unit/Billing/SubscriptionWebhookHandlerSkeletonTest.php

Comment on lines +17 to +24
$checkout = $user
->newSubscription('default', $plan->stripePriceId)
->checkout([
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
]);

return new CheckoutSessionDto(url: $checkout->url);

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Laravel Cashier 16 newSubscription checkout method return value and how to get the hosted checkout URL (url property)

💡 Result:

Laravel Cashier (cashier-stripe) v16: newSubscription(...)->checkout does NOT return a hosted checkout URL directly. 1) What checkout returns (v16) - In Cashier’s subscription checkout, the checkout call returns a Checkout object (Cashier’s wrapper around a Stripe Checkout Session). The Checkout object includes the Stripe session data such as the session id/url, but the method you call returns that Checkout wrapper rather than an already-built URL string/redirect response. [1][2] 2) How to get the hosted Stripe Checkout URL (the “url” property) - Convert the returned Checkout object to an array and read the url key. Example: $checkout = $request->user ->newSubscription('default', 'price_basic_monthly') ->checkout([ 'success_url' => route('checkout-success'), 'cancel_url' => route('checkout-cancel'), ]); $url = $checkout->toArray['url']; return redirect->away($url); This matches the documented behavior that Cashier’s provided Checkout wrapper exposes a url value via its serialized/array representation. [3] 3) Important note about “hosted checkout URL” vs embedded/custom UI - If you’re using Stripe Checkout UI modes, Cashier’s checkout options change what URL/redirect behavior you can expect (success_url/cancel_url vs return_url) depending on embedded UI/no-redirect scenarios. Cashier explicitly handles return_url vs success_url/cancel_url differences based on ui_mode and redirect_on_completion. [4][5] If you tell me whether you’re using the default hosted checkout UI mode or ui_mode=embedded/custom (and whether redirect_on_completion is set), I can tailor the exact snippet/option set for that mode.

Citations:


Confirm Cashier subscription checkout URL access ($checkout->url)

  • newSubscription(...)->checkout([...]) returns Cashier’s Checkout wrapper (not a raw URL string); the hosted Stripe Checkout redirect URL comes from the underlying Stripe Checkout Session (e.g. via $checkout->toArray()['url'], and commonly as $checkout->url as well).
  • For ui_mode=embedded/custom, Cashier swaps success_url/cancel_url for return_url; ensure the frontend/redirect flow matches that mode so you don’t rely on hosted checkout semantics that don’t apply there.
🤖 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/Billing/Gateway/CashierSubscriptionCheckoutGateway.php` around lines 17 -
24, The code currently assumes $checkout->url contains the Stripe hosted
redirect URL; inspect the Checkout object returned by
$user->newSubscription(...)->checkout(...) (symbol: newSubscription and
checkout) and update the logic to reliably extract the URL (e.g., prefer
$checkout->url if present, otherwise fallback to $checkout->toArray()['url'] or
the Checkout wrapper getter) before constructing CheckoutSessionDto; also detect
if Cashier is using ui_mode 'embedded' or 'custom' and, in that case, ensure the
frontend flow uses return_url semantics rather than relying on a hosted redirect
URL so you don't return an invalid URL from CheckoutSessionDto.

Comment thread app/Billing/Webhooks/SubscriptionWebhookHandler.php Outdated
Comment thread app/Billing/Webhooks/SubscriptionWebhookHandler.php Outdated
menvil and others added 2 commits June 2, 2026 20:07
- Replace firstOrFail() with firstOrCreate() on credit account lock:
  avoids ModelNotFoundException when credit account is missing
  (e.g. UserObserver failed); account is provisioned under the lock
- Replace SQLite-specific whereRaw json_extract with portable
  Laravel JSON operator (metadata_json->stripe_invoice_id) and
  scope alreadyGrantedForInvoice by user_id
- Use Plan::Free enum instead of hardcoded 'free' string in
  handleSubscriptionCancelled for type consistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix SubscriptionWebhookHandler followup issues
@menvil menvil merged commit e544ed3 into main Jun 2, 2026
3 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