Align commerce module with the monorepo module structure by updating all namespaces to use the Core\Mod\Commerce convention. This change supports the recent monorepo separation and ensures consistency with other modules. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
278 lines
9.5 KiB
PHP
278 lines
9.5 KiB
PHP
<?php
|
|
|
|
use Core\Mod\Commerce\Models\Payment;
|
|
use Core\Mod\Commerce\Models\Refund;
|
|
use Core\Mod\Commerce\Notifications\RefundProcessed;
|
|
use Core\Mod\Commerce\Services\CommerceService;
|
|
use Core\Mod\Commerce\Services\RefundService;
|
|
use Core\Mod\Tenant\Models\User;
|
|
use Core\Mod\Tenant\Models\Workspace;
|
|
use Illuminate\Support\Facades\Notification;
|
|
|
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|
|
|
beforeEach(function () {
|
|
Notification::fake();
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->workspace = Workspace::factory()->create();
|
|
$this->workspace->users()->attach($this->user->id, [
|
|
'role' => 'owner',
|
|
'is_default' => true,
|
|
]);
|
|
|
|
// Create a successful payment
|
|
$this->payment = Payment::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'gateway' => 'stripe',
|
|
'gateway_payment_id' => 'pi_test_123',
|
|
'amount' => 100.00,
|
|
'fee' => 0,
|
|
'net_amount' => 100.00,
|
|
'refunded_amount' => 0,
|
|
'currency' => 'GBP',
|
|
'status' => 'succeeded',
|
|
'paid_at' => now(),
|
|
]);
|
|
|
|
// Mock the gateway
|
|
$mockGateway = Mockery::mock(\Core\Mod\Commerce\Services\PaymentGateway\PaymentGatewayContract::class);
|
|
$mockGateway->shouldReceive('refund')->andReturn([
|
|
'success' => true,
|
|
'refund_id' => 're_test_123',
|
|
]);
|
|
|
|
$mockCommerce = Mockery::mock(CommerceService::class);
|
|
$mockCommerce->shouldReceive('getGateway')->andReturn($mockGateway);
|
|
|
|
$this->service = new RefundService($mockCommerce);
|
|
});
|
|
|
|
afterEach(function () {
|
|
Mockery::close();
|
|
});
|
|
|
|
describe('RefundService', function () {
|
|
describe('refund() method', function () {
|
|
it('processes a partial refund', function () {
|
|
$refund = $this->service->refund(
|
|
$this->payment,
|
|
50.00,
|
|
'requested_by_customer',
|
|
'Customer changed mind'
|
|
);
|
|
|
|
expect($refund)->toBeInstanceOf(Refund::class)
|
|
->and((float) $refund->amount)->toBe(50.00)
|
|
->and($refund->status)->toBe('succeeded')
|
|
->and($refund->reason)->toBe('requested_by_customer')
|
|
->and($refund->notes)->toBe('Customer changed mind');
|
|
});
|
|
|
|
it('sends notification on successful refund', function () {
|
|
$this->service->refund($this->payment, 50.00);
|
|
|
|
Notification::assertSentTo(
|
|
$this->user,
|
|
RefundProcessed::class
|
|
);
|
|
});
|
|
|
|
it('records who initiated the refund', function () {
|
|
$admin = User::factory()->create();
|
|
|
|
$refund = $this->service->refund(
|
|
$this->payment,
|
|
50.00,
|
|
initiatedBy: $admin
|
|
);
|
|
|
|
expect($refund->initiated_by)->toBe($admin->id);
|
|
});
|
|
|
|
it('throws exception for refund exceeding available amount', function () {
|
|
expect(fn () => $this->service->refund($this->payment, 150.00))
|
|
->toThrow(\InvalidArgumentException::class, 'exceeds maximum refundable');
|
|
});
|
|
|
|
it('throws exception for zero or negative amount', function () {
|
|
expect(fn () => $this->service->refund($this->payment, 0))
|
|
->toThrow(\InvalidArgumentException::class, 'greater than zero');
|
|
|
|
expect(fn () => $this->service->refund($this->payment, -50.00))
|
|
->toThrow(\InvalidArgumentException::class, 'greater than zero');
|
|
});
|
|
|
|
it('throws exception for non-succeeded payments', function () {
|
|
$pendingPayment = Payment::create([
|
|
'workspace_id' => $this->workspace->id,
|
|
'gateway' => 'stripe',
|
|
'gateway_payment_id' => 'pi_pending_123',
|
|
'amount' => 100.00,
|
|
'currency' => 'GBP',
|
|
'status' => 'pending',
|
|
]);
|
|
|
|
expect(fn () => $this->service->refund($pendingPayment, 50.00))
|
|
->toThrow(\InvalidArgumentException::class, 'only refund successful payments');
|
|
});
|
|
|
|
it('allows multiple partial refunds up to full amount', function () {
|
|
// First refund
|
|
$refund1 = $this->service->refund($this->payment, 30.00);
|
|
$this->payment->refresh();
|
|
|
|
// Second refund
|
|
$refund2 = $this->service->refund($this->payment, 40.00);
|
|
$this->payment->refresh();
|
|
|
|
// Third refund for remaining
|
|
$refund3 = $this->service->refund($this->payment, 30.00);
|
|
|
|
expect((float) $refund1->amount)->toBe(30.00)
|
|
->and((float) $refund2->amount)->toBe(40.00)
|
|
->and((float) $refund3->amount)->toBe(30.00);
|
|
});
|
|
});
|
|
|
|
describe('refundFull() method', function () {
|
|
it('refunds the full payment amount', function () {
|
|
$refund = $this->service->refundFull($this->payment);
|
|
|
|
expect((float) $refund->amount)->toBe(100.00);
|
|
});
|
|
|
|
it('refunds remaining amount after partial refund', function () {
|
|
// Partial refund first
|
|
$this->service->refund($this->payment, 40.00);
|
|
$this->payment->refresh();
|
|
|
|
// Full refund of remainder
|
|
$refund = $this->service->refundFull($this->payment);
|
|
|
|
expect((float) $refund->amount)->toBe(60.00);
|
|
});
|
|
});
|
|
|
|
describe('canRefund() method', function () {
|
|
it('returns true for refundable payment', function () {
|
|
expect($this->service->canRefund($this->payment))->toBeTrue();
|
|
});
|
|
|
|
it('returns false for pending payment', function () {
|
|
$this->payment->update(['status' => 'pending']);
|
|
|
|
expect($this->service->canRefund($this->payment))->toBeFalse();
|
|
});
|
|
|
|
it('returns false for fully refunded payment', function () {
|
|
$this->payment->update(['refunded_amount' => 100.00, 'status' => 'refunded']);
|
|
|
|
expect($this->service->canRefund($this->payment))->toBeFalse();
|
|
});
|
|
|
|
it('returns false for payment outside refund window', function () {
|
|
// Force update created_at directly to bypass timestamp protection
|
|
Payment::withoutTimestamps(function () {
|
|
$this->payment->created_at = now()->subDays(200);
|
|
$this->payment->save();
|
|
});
|
|
$this->payment->refresh();
|
|
|
|
expect($this->service->canRefund($this->payment))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('getMaxRefundableAmount() method', function () {
|
|
it('returns full amount for unrefunded payment', function () {
|
|
expect($this->service->getMaxRefundableAmount($this->payment))->toBe(100.00);
|
|
});
|
|
|
|
it('returns remaining amount after partial refund', function () {
|
|
$this->payment->update(['refunded_amount' => 40.00]);
|
|
$this->payment->refresh();
|
|
|
|
expect($this->service->getMaxRefundableAmount($this->payment))->toBe(60.00);
|
|
});
|
|
|
|
it('returns zero for fully refunded payment', function () {
|
|
$this->payment->update(['refunded_amount' => 100.00]);
|
|
$this->payment->refresh();
|
|
|
|
expect($this->service->getMaxRefundableAmount($this->payment))->toBe(0.00);
|
|
});
|
|
});
|
|
|
|
describe('getRefundsForPayment() method', function () {
|
|
it('returns all refunds for a payment', function () {
|
|
// Create some refunds directly
|
|
Refund::create([
|
|
'payment_id' => $this->payment->id,
|
|
'amount' => 25.00,
|
|
'currency' => 'GBP',
|
|
'status' => 'succeeded',
|
|
'reason' => 'requested_by_customer',
|
|
]);
|
|
|
|
Refund::create([
|
|
'payment_id' => $this->payment->id,
|
|
'amount' => 25.00,
|
|
'currency' => 'GBP',
|
|
'status' => 'succeeded',
|
|
'reason' => 'duplicate',
|
|
]);
|
|
|
|
$refunds = $this->service->getRefundsForPayment($this->payment);
|
|
|
|
expect($refunds)->toHaveCount(2);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Refund model', function () {
|
|
it('marks refund as succeeded', function () {
|
|
$refund = Refund::create([
|
|
'payment_id' => $this->payment->id,
|
|
'amount' => 50.00,
|
|
'currency' => 'GBP',
|
|
'status' => 'pending',
|
|
'reason' => 'requested_by_customer',
|
|
]);
|
|
|
|
$refund->markAsSucceeded('re_test_456');
|
|
|
|
expect($refund->status)->toBe('succeeded')
|
|
->and($refund->gateway_refund_id)->toBe('re_test_456');
|
|
|
|
// Check payment refunded_amount was updated
|
|
$this->payment->refresh();
|
|
expect((float) $this->payment->refunded_amount)->toBe(50.00);
|
|
});
|
|
|
|
it('marks refund as failed', function () {
|
|
$refund = Refund::create([
|
|
'payment_id' => $this->payment->id,
|
|
'amount' => 50.00,
|
|
'currency' => 'GBP',
|
|
'status' => 'pending',
|
|
'reason' => 'requested_by_customer',
|
|
]);
|
|
|
|
$refund->markAsFailed(['error' => 'Insufficient funds']);
|
|
|
|
expect($refund->status)->toBe('failed')
|
|
->and($refund->gateway_response)->toMatchArray(['error' => 'Insufficient funds']);
|
|
});
|
|
|
|
it('gets human-readable reason label', function () {
|
|
$refund = Refund::create([
|
|
'payment_id' => $this->payment->id,
|
|
'amount' => 50.00,
|
|
'currency' => 'GBP',
|
|
'status' => 'succeeded',
|
|
'reason' => 'requested_by_customer',
|
|
]);
|
|
|
|
expect($refund->getReasonLabel())->toBe('Customer request');
|
|
});
|
|
});
|