Compare commits
2 commits
dev
...
feat/test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
230c58a688 | ||
|
|
5bce748a0f |
2 changed files with 989 additions and 12 deletions
|
|
@ -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
|
// Billing overview
|
||||||
Route::get('/billing', [CommerceController::class, 'billing'])
|
Route::get('/billing', [CommerceController::class, 'billing'])
|
||||||
->name('api.commerce.billing');
|
->name('api.commerce.billing');
|
||||||
|
|
@ -74,21 +76,27 @@ Route::middleware('auth')->prefix('commerce')->group(function () {
|
||||||
Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice'])
|
Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice'])
|
||||||
->name('api.commerce.invoices.download');
|
->name('api.commerce.invoices.download');
|
||||||
|
|
||||||
// Subscription
|
// Subscription (read)
|
||||||
Route::get('/subscription', [CommerceController::class, 'subscription'])
|
Route::get('/subscription', [CommerceController::class, 'subscription'])
|
||||||
->name('api.commerce.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
|
// Usage
|
||||||
Route::get('/usage', [CommerceController::class, 'usage'])
|
Route::get('/usage', [CommerceController::class, 'usage'])
|
||||||
->name('api.commerce.usage');
|
->name('api.commerce.usage');
|
||||||
|
|
||||||
|
// ── 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
|
// Plan changes
|
||||||
Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade'])
|
Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade'])
|
||||||
->name('api.commerce.upgrade.preview');
|
->name('api.commerce.upgrade.preview');
|
||||||
Route::post('/upgrade', [CommerceController::class, 'executeUpgrade'])
|
Route::post('/upgrade', [CommerceController::class, 'executeUpgrade'])
|
||||||
->name('api.commerce.upgrade');
|
->name('api.commerce.upgrade');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
969
tests/Feature/PaymentMethodServiceTest.php
Normal file
969
tests/Feature/PaymentMethodServiceTest.php
Normal file
|
|
@ -0,0 +1,969 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Mod\Commerce\Models\PaymentMethod;
|
||||||
|
use Core\Mod\Commerce\Models\Subscription;
|
||||||
|
use Core\Mod\Commerce\Services\PaymentGateway\StripeGateway;
|
||||||
|
use Core\Mod\Commerce\Services\PaymentMethodService;
|
||||||
|
use Core\Tenant\Models\Package;
|
||||||
|
use Core\Tenant\Models\User;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Core\Tenant\Models\WorkspacePackage;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue