From 5bce748a0faa52eb8cce31839d8fe19592626f92 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 16:19:30 +0000 Subject: [PATCH 1/2] security: add CSRF protection to API billing endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `verified` middleware to billing route group so only email-verified users can access billing endpoints - Separate read-only GET routes from state-changing POST routes - Add `throttle:6,1` rate limiting to state-changing endpoints (cancel, resume, upgrade/preview, upgrade) — 6 requests per minute - Reorganise route group with clear section comments Fixes #13 Co-Authored-By: Claude Opus 4.6 (1M context) --- routes/api.php | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/routes/api.php b/routes/api.php index 23d1b63..1c0e3fc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,10 +52,12 @@ Route::prefix('webhooks')->group(function () { // }); // ───────────────────────────────────────────────────────────────────────────── -// Commerce Billing API (authenticated) +// Commerce Billing API (authenticated + verified) // ───────────────────────────────────────────────────────────────────────────── -Route::middleware('auth')->prefix('commerce')->group(function () { +Route::middleware(['auth', 'verified'])->prefix('commerce')->group(function () { + // ── Read-only endpoints ────────────────────────────────────────────── + // Billing overview Route::get('/billing', [CommerceController::class, 'billing']) ->name('api.commerce.billing'); @@ -74,21 +76,27 @@ Route::middleware('auth')->prefix('commerce')->group(function () { Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice']) ->name('api.commerce.invoices.download'); - // Subscription + // Subscription (read) Route::get('/subscription', [CommerceController::class, 'subscription']) ->name('api.commerce.subscription'); - Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) - ->name('api.commerce.cancel'); - Route::post('/resume', [CommerceController::class, 'resumeSubscription']) - ->name('api.commerce.resume'); // Usage Route::get('/usage', [CommerceController::class, 'usage']) ->name('api.commerce.usage'); - // Plan changes - Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) - ->name('api.commerce.upgrade.preview'); - Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) - ->name('api.commerce.upgrade'); + // ── State-changing endpoints (rate-limited) ────────────────────────── + + Route::middleware('throttle:6,1')->group(function () { + // Subscription management + Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) + ->name('api.commerce.cancel'); + Route::post('/resume', [CommerceController::class, 'resumeSubscription']) + ->name('api.commerce.resume'); + + // Plan changes + Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) + ->name('api.commerce.upgrade.preview'); + Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) + ->name('api.commerce.upgrade'); + }); }); -- 2.45.3 From 230c58a688d61c7dd70eb307f7596667839c9392 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 16:23:55 +0000 Subject: [PATCH 2/2] test: add comprehensive tests for PaymentMethodService Cover CRUD operations, default method selection logic, Stripe gateway mocking, validation (ownership, last-method guard), expiry detection, gateway data updates, setup sessions, billing portal, and PaymentMethod model helpers/scopes. Fixes #4 Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/Feature/PaymentMethodServiceTest.php | 969 +++++++++++++++++++++ 1 file changed, 969 insertions(+) create mode 100644 tests/Feature/PaymentMethodServiceTest.php diff --git a/tests/Feature/PaymentMethodServiceTest.php b/tests/Feature/PaymentMethodServiceTest.php new file mode 100644 index 0000000..87da483 --- /dev/null +++ b/tests/Feature/PaymentMethodServiceTest.php @@ -0,0 +1,969 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create([ + 'stripe_customer_id' => 'cus_test_123', + ]); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Mock the StripeGateway + $this->mockStripe = Mockery::mock(StripeGateway::class); + + $this->service = new PaymentMethodService($this->mockStripe); +}); + +afterEach(function () { + Mockery::close(); +}); + +describe('PaymentMethodService', function () { + describe('getPaymentMethods()', function () { + it('returns only active payment methods for a workspace', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_active_1', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'is_active' => true, + 'is_default' => true, + ]); + + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_active_2', + 'type' => 'card', + 'brand' => 'mastercard', + 'last_four' => '5555', + 'is_active' => true, + 'is_default' => false, + ]); + + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_inactive', + 'type' => 'card', + 'brand' => 'amex', + 'last_four' => '0001', + 'is_active' => false, + 'is_default' => false, + ]); + + $methods = $this->service->getPaymentMethods($this->workspace); + + expect($methods)->toHaveCount(2) + ->and($methods->pluck('gateway_payment_method_id')->toArray()) + ->not->toContain('pm_inactive'); + }); + + it('orders default payment method first', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_non_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $methods = $this->service->getPaymentMethods($this->workspace); + + expect($methods->first()->gateway_payment_method_id)->toBe('pm_default'); + }); + + it('returns empty collection when no active methods exist', function () { + $methods = $this->service->getPaymentMethods($this->workspace); + + expect($methods)->toBeEmpty(); + }); + }); + + describe('getDefaultPaymentMethod()', function () { + it('returns the default active payment method', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_non_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $default = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $result = $this->service->getDefaultPaymentMethod($this->workspace); + + expect($result)->not->toBeNull() + ->and($result->id)->toBe($default->id); + }); + + it('returns null when no default method exists', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_no_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $result = $this->service->getDefaultPaymentMethod($this->workspace); + + expect($result)->toBeNull(); + }); + + it('ignores inactive default methods', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_inactive_default', + 'type' => 'card', + 'is_active' => false, + 'is_default' => true, + ]); + + $result = $this->service->getDefaultPaymentMethod($this->workspace); + + expect($result)->toBeNull(); + }); + }); + + describe('addPaymentMethod()', function () { + it('adds a new Stripe payment method via gateway', function () { + $createdMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_new_123', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'exp_month' => 12, + 'exp_year' => 2028, + 'is_default' => false, + 'is_active' => true, + ]); + + $this->mockStripe->shouldReceive('attachPaymentMethod') + ->once() + ->with(Mockery::on(fn ($ws) => $ws->id === $this->workspace->id), 'pm_new_123') + ->andReturn($createdMethod); + + $this->mockStripe->shouldReceive('setDefaultPaymentMethod') + ->once(); + + $result = $this->service->addPaymentMethod( + $this->workspace, + 'pm_new_123', + $this->user + ); + + expect($result)->toBeInstanceOf(PaymentMethod::class) + ->and($result->gateway_payment_method_id)->toBe('pm_new_123') + ->and($result->brand)->toBe('visa') + ->and($result->last_four)->toBe('4242'); + }); + + it('reactivates an existing inactive payment method', function () { + $existing = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_existing_123', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'is_active' => false, + 'is_default' => false, + ]); + + $result = $this->service->addPaymentMethod( + $this->workspace, + 'pm_existing_123' + ); + + expect($result->id)->toBe($existing->id) + ->and($result->fresh()->is_active)->toBeTrue(); + }); + + it('returns existing active payment method without duplicating', function () { + $existing = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_already_active', + 'type' => 'card', + 'brand' => 'mastercard', + 'last_four' => '5555', + 'is_active' => true, + 'is_default' => false, + ]); + + $result = $this->service->addPaymentMethod( + $this->workspace, + 'pm_already_active' + ); + + expect($result->id)->toBe($existing->id); + expect(PaymentMethod::where('gateway_payment_method_id', 'pm_already_active')->count())->toBe(1); + }); + + it('sets first payment method as default automatically', function () { + $createdMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_first', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '1234', + 'is_default' => false, + 'is_active' => true, + ]); + + $this->mockStripe->shouldReceive('attachPaymentMethod') + ->once() + ->andReturn($createdMethod); + + $this->mockStripe->shouldReceive('setDefaultPaymentMethod') + ->once() + ->with(Mockery::on(fn ($pm) => $pm->id === $createdMethod->id)); + + $result = $this->service->addPaymentMethod( + $this->workspace, + 'pm_first' + ); + + expect($result->id)->toBe($createdMethod->id); + }); + + it('does not override default when other methods exist', function () { + // Pre-existing active method + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_existing_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $newMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_second', + 'type' => 'card', + 'brand' => 'amex', + 'last_four' => '0005', + 'is_default' => false, + 'is_active' => true, + ]); + + $this->mockStripe->shouldReceive('attachPaymentMethod') + ->once() + ->andReturn($newMethod); + + // setDefaultPaymentMethod should NOT be called + $this->mockStripe->shouldNotReceive('setDefaultPaymentMethod'); + + $this->service->addPaymentMethod($this->workspace, 'pm_second'); + }); + + it('assigns user to payment method when provided', function () { + $createdMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_with_user', + 'type' => 'card', + 'is_default' => false, + 'is_active' => true, + ]); + + $this->mockStripe->shouldReceive('attachPaymentMethod') + ->once() + ->andReturn($createdMethod); + + $this->mockStripe->shouldReceive('setDefaultPaymentMethod') + ->once(); + + $result = $this->service->addPaymentMethod( + $this->workspace, + 'pm_with_user', + $this->user + ); + + expect($result->fresh()->user_id)->toBe($this->user->id); + }); + + it('throws exception for unsupported gateway', function () { + expect(fn () => $this->service->addPaymentMethod( + $this->workspace, + 'btc_method_123', + null, + 'unsupported_gateway' + ))->toThrow(InvalidArgumentException::class, 'Unsupported payment gateway'); + }); + }); + + describe('removePaymentMethod()', function () { + it('deactivates a payment method', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_to_remove', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $this->mockStripe->shouldReceive('detachPaymentMethod') + ->once() + ->with(Mockery::on(fn ($pm) => $pm->id === $method->id)); + + $this->service->removePaymentMethod($this->workspace, $method); + + expect($method->fresh()->is_active)->toBeFalse(); + }); + + it('throws exception when method does not belong to workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $method = PaymentMethod::create([ + 'workspace_id' => $otherWorkspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_other_ws', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + expect(fn () => $this->service->removePaymentMethod($this->workspace, $method)) + ->toThrow(RuntimeException::class, 'does not belong to this workspace'); + }); + + it('throws exception when removing last method with active subscription', function () { + $package = Package::where('code', 'creator')->first(); + $workspacePackage = WorkspacePackage::create([ + 'workspace_id' => $this->workspace->id, + 'package_id' => $package->id, + 'status' => 'active', + ]); + + Subscription::create([ + 'workspace_id' => $this->workspace->id, + 'workspace_package_id' => $workspacePackage->id, + 'status' => 'active', + 'gateway' => 'stripe', + 'billing_cycle' => 'monthly', + 'current_period_start' => now(), + 'current_period_end' => now()->addDays(30), + ]); + + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_last_one', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + expect(fn () => $this->service->removePaymentMethod($this->workspace, $method)) + ->toThrow(RuntimeException::class, 'Cannot remove the last payment method'); + }); + + it('allows removing last method when no active subscriptions', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_last_no_sub', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $this->mockStripe->shouldReceive('detachPaymentMethod')->once(); + + $this->service->removePaymentMethod($this->workspace, $method); + + expect($method->fresh()->is_active)->toBeFalse(); + }); + + it('promotes another method to default when removing the default', function () { + $defaultMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_default_remove', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $otherMethod = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_becomes_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $this->mockStripe->shouldReceive('detachPaymentMethod')->once(); + $this->mockStripe->shouldReceive('setDefaultPaymentMethod') + ->once() + ->with(Mockery::on(fn ($pm) => $pm->id === $otherMethod->id)); + + $this->service->removePaymentMethod($this->workspace, $defaultMethod); + + expect($defaultMethod->fresh()->is_active)->toBeFalse() + ->and($otherMethod->fresh()->is_default)->toBeTrue(); + }); + + it('continues removal even if Stripe detach fails', function () { + Log::shouldReceive('warning')->once(); + Log::shouldReceive('info')->once(); + + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_stripe_fail', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $this->mockStripe->shouldReceive('detachPaymentMethod') + ->once() + ->andThrow(new Exception('Stripe API error')); + + $this->service->removePaymentMethod($this->workspace, $method); + + expect($method->fresh()->is_active)->toBeFalse(); + }); + }); + + describe('setDefaultPaymentMethod()', function () { + it('sets a payment method as default', function () { + $method1 = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_old_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $method2 = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_new_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $this->mockStripe->shouldReceive('setDefaultPaymentMethod') + ->once() + ->with(Mockery::on(fn ($pm) => $pm->id === $method2->id)); + + $this->service->setDefaultPaymentMethod($this->workspace, $method2); + + expect($method1->fresh()->is_default)->toBeFalse() + ->and($method2->fresh()->is_default)->toBeTrue(); + }); + + it('throws exception when method does not belong to workspace', function () { + $otherWorkspace = Workspace::factory()->create(); + $method = PaymentMethod::create([ + 'workspace_id' => $otherWorkspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_wrong_ws', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + expect(fn () => $this->service->setDefaultPaymentMethod($this->workspace, $method)) + ->toThrow(RuntimeException::class, 'does not belong to this workspace'); + }); + + it('clears default from all other methods in same workspace', function () { + $method1 = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_was_default_1', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $method2 = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_was_default_2', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $method3 = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_make_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $this->mockStripe->shouldReceive('setDefaultPaymentMethod')->once(); + + $this->service->setDefaultPaymentMethod($this->workspace, $method3); + + expect($method1->fresh()->is_default)->toBeFalse() + ->and($method2->fresh()->is_default)->toBeFalse() + ->and($method3->fresh()->is_default)->toBeTrue(); + }); + + it('continues setting default locally even if Stripe call fails', function () { + Log::shouldReceive('warning')->once(); + Log::shouldReceive('info')->once(); + + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_stripe_default_fail', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $this->mockStripe->shouldReceive('setDefaultPaymentMethod') + ->once() + ->andThrow(new Exception('Stripe API error')); + + $this->service->setDefaultPaymentMethod($this->workspace, $method); + + expect($method->fresh()->is_default)->toBeTrue(); + }); + }); + + describe('syncPaymentMethodsFromStripe()', function () { + it('returns empty collection when workspace has no Stripe customer ID', function () { + $workspace = Workspace::factory()->create(['stripe_customer_id' => null]); + + $result = $this->service->syncPaymentMethodsFromStripe($workspace); + + expect($result)->toBeEmpty(); + }); + + it('returns existing payment methods for workspace with Stripe customer', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_synced', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $result = $this->service->syncPaymentMethodsFromStripe($this->workspace); + + expect($result)->toHaveCount(1) + ->and($result->first()->gateway_payment_method_id)->toBe('pm_synced'); + }); + }); + + describe('isExpiringSoon()', function () { + it('returns true when card expires within threshold', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_expiring', + 'type' => 'card', + 'exp_month' => (int) now()->addMonth()->format('m'), + 'exp_year' => (int) now()->addMonth()->format('Y'), + 'is_active' => true, + 'is_default' => true, + ]); + + expect($this->service->isExpiringSoon($method, 2))->toBeTrue(); + }); + + it('returns false when card is not expiring soon', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_not_expiring', + 'type' => 'card', + 'exp_month' => 12, + 'exp_year' => (int) now()->addYears(3)->format('Y'), + 'is_active' => true, + 'is_default' => true, + ]); + + expect($this->service->isExpiringSoon($method))->toBeFalse(); + }); + + it('returns false when expiry data is missing', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_no_expiry', + 'type' => 'crypto_wallet', + 'exp_month' => null, + 'exp_year' => null, + 'is_active' => true, + 'is_default' => false, + ]); + + expect($this->service->isExpiringSoon($method))->toBeFalse(); + }); + + it('respects custom month threshold', function () { + // Card expiring in 5 months + $futureDate = now()->addMonths(5); + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_custom_threshold', + 'type' => 'card', + 'exp_month' => (int) $futureDate->format('m'), + 'exp_year' => (int) $futureDate->format('Y'), + 'is_active' => true, + 'is_default' => false, + ]); + + expect($this->service->isExpiringSoon($method, 2))->toBeFalse() + ->and($this->service->isExpiringSoon($method, 6))->toBeTrue(); + }); + }); + + describe('updateFromGateway()', function () { + it('updates card details from gateway data', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_update_card', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'exp_month' => 1, + 'exp_year' => 2025, + 'is_active' => true, + 'is_default' => true, + ]); + + $result = $this->service->updateFromGateway($method, [ + 'card' => [ + 'brand' => 'visa', + 'last4' => '4242', + 'exp_month' => 6, + 'exp_year' => 2029, + ], + ]); + + expect($result->exp_month)->toBe(6) + ->and($result->exp_year)->toBe(2029) + ->and($result->brand)->toBe('visa') + ->and($result->last_four)->toBe('4242'); + }); + + it('preserves existing values for missing gateway fields', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_partial_update', + 'type' => 'card', + 'brand' => 'mastercard', + 'last_four' => '5555', + 'exp_month' => 3, + 'exp_year' => 2027, + 'is_active' => true, + 'is_default' => false, + ]); + + $result = $this->service->updateFromGateway($method, [ + 'card' => [ + 'exp_month' => 9, + 'exp_year' => 2028, + ], + ]); + + expect($result->brand)->toBe('mastercard') + ->and($result->last_four)->toBe('5555') + ->and($result->exp_month)->toBe(9) + ->and($result->exp_year)->toBe(2028); + }); + + it('does nothing when no card data is provided', function () { + $method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_no_card_data', + 'type' => 'card', + 'brand' => 'amex', + 'last_four' => '0001', + 'exp_month' => 12, + 'exp_year' => 2026, + 'is_active' => true, + 'is_default' => false, + ]); + + $result = $this->service->updateFromGateway($method, []); + + expect($result->brand)->toBe('amex') + ->and($result->exp_month)->toBe(12) + ->and($result->exp_year)->toBe(2026); + }); + }); + + describe('createSetupSession()', function () { + it('creates a setup session via Stripe gateway', function () { + $this->mockStripe->shouldReceive('isEnabled') + ->once() + ->andReturn(true); + + $this->mockStripe->shouldReceive('createSetupSession') + ->once() + ->with( + Mockery::on(fn ($ws) => $ws->id === $this->workspace->id), + 'https://example.com/return' + ) + ->andReturn([ + 'session_id' => 'seti_test_123', + 'setup_url' => 'https://checkout.stripe.com/setup/test', + ]); + + $result = $this->service->createSetupSession( + $this->workspace, + 'https://example.com/return' + ); + + expect($result)->toHaveKeys(['session_id', 'setup_url']) + ->and($result['session_id'])->toBe('seti_test_123'); + }); + + it('throws exception when Stripe is not enabled', function () { + $this->mockStripe->shouldReceive('isEnabled') + ->once() + ->andReturn(false); + + expect(fn () => $this->service->createSetupSession( + $this->workspace, + 'https://example.com/return' + ))->toThrow(RuntimeException::class, 'not currently available'); + }); + }); + + describe('getBillingPortalUrl()', function () { + it('returns portal URL when Stripe is enabled', function () { + $this->mockStripe->shouldReceive('isEnabled') + ->once() + ->andReturn(true); + + $this->mockStripe->shouldReceive('getPortalUrl') + ->once() + ->with( + Mockery::on(fn ($ws) => $ws->id === $this->workspace->id), + 'https://example.com/billing' + ) + ->andReturn('https://billing.stripe.com/session/test_portal'); + + $result = $this->service->getBillingPortalUrl( + $this->workspace, + 'https://example.com/billing' + ); + + expect($result)->toBe('https://billing.stripe.com/session/test_portal'); + }); + + it('returns null when Stripe is not enabled', function () { + $this->mockStripe->shouldReceive('isEnabled') + ->once() + ->andReturn(false); + + $result = $this->service->getBillingPortalUrl( + $this->workspace, + 'https://example.com/billing' + ); + + expect($result)->toBeNull(); + }); + }); +}); + +describe('PaymentMethod model', function () { + beforeEach(function () { + $this->method = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_model_test', + 'type' => 'card', + 'brand' => 'visa', + 'last_four' => '4242', + 'exp_month' => 12, + 'exp_year' => 2028, + 'is_active' => true, + 'is_default' => false, + ]); + }); + + it('identifies card type correctly', function () { + expect($this->method->isCard())->toBeTrue() + ->and($this->method->isCrypto())->toBeFalse() + ->and($this->method->isBankAccount())->toBeFalse(); + }); + + it('identifies crypto wallet type correctly', function () { + $this->method->update(['type' => 'crypto_wallet']); + + expect($this->method->isCrypto())->toBeTrue() + ->and($this->method->isCard())->toBeFalse(); + }); + + it('identifies bank account type correctly', function () { + $this->method->update(['type' => 'bank_account']); + + expect($this->method->isBankAccount())->toBeTrue() + ->and($this->method->isCard())->toBeFalse(); + }); + + it('detects expired cards', function () { + $this->method->update([ + 'exp_month' => 1, + 'exp_year' => 2020, + ]); + + expect($this->method->isExpired())->toBeTrue(); + }); + + it('detects non-expired cards', function () { + expect($this->method->isExpired())->toBeFalse(); + }); + + it('returns false for expiry check when no expiry data', function () { + $this->method->update([ + 'exp_month' => null, + 'exp_year' => null, + ]); + + expect($this->method->isExpired())->toBeFalse(); + }); + + it('generates display name for card', function () { + expect($this->method->getDisplayName())->toBe('Visa **** 4242'); + }); + + it('generates display name for crypto wallet', function () { + $this->method->update(['type' => 'crypto_wallet']); + + expect($this->method->getDisplayName())->toBe('Crypto Wallet'); + }); + + it('generates display name for bank account', function () { + $this->method->update(['type' => 'bank_account']); + + expect($this->method->getDisplayName())->toBe('Bank Account'); + }); + + it('generates display name for card without brand', function () { + $this->method->update(['brand' => null]); + + expect($this->method->getDisplayName())->toBe('Card **** 4242'); + }); + + it('sets itself as default and clears others', function () { + $other = PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_other_default', + 'type' => 'card', + 'is_active' => true, + 'is_default' => true, + ]); + + $this->method->setAsDefault(); + + expect($this->method->fresh()->is_default)->toBeTrue() + ->and($other->fresh()->is_default)->toBeFalse(); + }); + + it('deactivates itself', function () { + $this->method->deactivate(); + + expect($this->method->fresh()->is_active)->toBeFalse(); + }); + + it('uses active scope correctly', function () { + PaymentMethod::create([ + 'workspace_id' => $this->workspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_inactive_scope', + 'type' => 'card', + 'is_active' => false, + 'is_default' => false, + ]); + + $active = PaymentMethod::active()->get(); + + expect($active->pluck('is_active')->unique()->toArray())->toBe([true]); + }); + + it('uses default scope correctly', function () { + $this->method->update(['is_default' => true]); + + $defaults = PaymentMethod::default()->get(); + + expect($defaults)->toHaveCount(1) + ->and($defaults->first()->id)->toBe($this->method->id); + }); + + it('uses forWorkspace scope correctly', function () { + $otherWorkspace = Workspace::factory()->create(); + PaymentMethod::create([ + 'workspace_id' => $otherWorkspace->id, + 'gateway' => 'stripe', + 'gateway_payment_method_id' => 'pm_other_ws_scope', + 'type' => 'card', + 'is_active' => true, + 'is_default' => false, + ]); + + $methods = PaymentMethod::forWorkspace($this->workspace->id)->get(); + + expect($methods->pluck('workspace_id')->unique()->toArray())->toBe([$this->workspace->id]); + }); +}); -- 2.45.3