diff --git a/packages/admin/src/Filament/Resources/OrderResource/Pages/Components/OrderItemsTable.php b/packages/admin/src/Filament/Resources/OrderResource/Pages/Components/OrderItemsTable.php index b67772145d..dc27c342b4 100644 --- a/packages/admin/src/Filament/Resources/OrderResource/Pages/Components/OrderItemsTable.php +++ b/packages/admin/src/Filament/Resources/OrderResource/Pages/Components/OrderItemsTable.php @@ -21,10 +21,12 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\HtmlString; use Livewire\Attributes\Computed; +use Lunar\Actions\Orders\RefundTransaction; use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct; use Lunar\Admin\Livewire\Components\TableComponent; use Lunar\Admin\Support\Concerns\CallsHooks; use Lunar\Admin\Support\Tables\Components\KeyValue; +use Lunar\Base\DataTransferObjects\RefundRequest; use Lunar\Models\ProductVariant; use Lunar\Models\Transaction; @@ -185,8 +187,23 @@ function () { ]) ->action(function ($data, BulkAction $action) { $transaction = Transaction::findOrFail($data['transaction']); - - $response = $transaction->refund(bcmul($data['amount'], $this->record->currency->factor), $data['notes']); + $selectedLines = $this->record->lines() + ->whereIn('id', $this->selectedTableRecords) + ->get(); + + $response = app(RefundTransaction::class)->execute( + new RefundRequest( + transaction: $transaction, + amount: (int) bcmul($data['amount'], $this->record->currency->factor), + notes: $data['notes'], + actorId: auth('staff')->id() ?: auth()->id(), + lineAllocations: $selectedLines->map(fn ($line) => [ + 'order_line_id' => $line->id, + 'quantity' => $line->quantity, + 'amount' => $line->total->value, + ])->values()->all(), + ) + ); if (! $response->success) { $action->failureNotification( diff --git a/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php b/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php index c4ff4e682b..a3c32da872 100644 --- a/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php +++ b/packages/admin/src/Filament/Resources/OrderResource/Pages/ManageOrder.php @@ -25,6 +25,7 @@ use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\Collection; use Livewire\Attributes\Computed; +use Lunar\Actions\Orders\RefundTransaction; use Lunar\Admin\Filament\Resources\CustomerResource; use Lunar\Admin\Filament\Resources\OrderResource; use Lunar\Admin\Filament\Resources\OrderResource\Concerns\DisplaysOrderAddresses; @@ -42,6 +43,7 @@ use Lunar\Admin\Support\Infolists\Components\Livewire; use Lunar\Admin\Support\Infolists\Components\Tags; use Lunar\Admin\Support\Pages\BaseViewRecord; +use Lunar\Base\DataTransferObjects\RefundRequest; use Lunar\Models\Order; use Lunar\Models\Tag; use Lunar\Models\Transaction; @@ -426,7 +428,14 @@ function () { ->action(function ($data, $record, Action $action) { $transaction = Transaction::findOrFail($data['transaction']); - $response = $transaction->refund(bcmul($data['amount'], $record->currency->factor), $data['notes']); + $response = app(RefundTransaction::class)->execute( + new RefundRequest( + transaction: $transaction, + amount: (int) bcmul($data['amount'], $record->currency->factor), + notes: $data['notes'], + actorId: auth('staff')->id() ?: auth()->id(), + ) + ); if (! $response->success) { $action->failureNotification( diff --git a/packages/core/src/Actions/Orders/RefundTransaction.php b/packages/core/src/Actions/Orders/RefundTransaction.php new file mode 100644 index 0000000000..6d6e2e6233 --- /dev/null +++ b/packages/core/src/Actions/Orders/RefundTransaction.php @@ -0,0 +1,109 @@ +refundAuthorization->authorize($refundRequest); + + if (! $authorization->authorized) { + $paymentRefund = new PaymentRefund( + success: false, + message: $authorization->message, + lineAllocations: $refundRequest->lineAllocations, + ); + + RefundFailed::dispatch( + refundRequest: $refundRequest, + paymentRefund: $paymentRefund, + message: $authorization->message, + meta: $authorization->meta, + ); + + return $paymentRefund; + } + + RefundRequested::dispatch($refundRequest); + + $existingRefundIds = $refundRequest->transaction->order + ->refunds() + ->pluck('id'); + + try { + $paymentRefund = $refundRequest->transaction->refund( + $refundRequest->amount, + $refundRequest->notes, + ); + } catch (Throwable $e) { + $paymentRefund = new PaymentRefund( + success: false, + message: $e->getMessage(), + lineAllocations: $refundRequest->lineAllocations, + ); + + RefundFailed::dispatch( + refundRequest: $refundRequest, + paymentRefund: $paymentRefund, + message: $e->getMessage(), + meta: $authorization->meta, + ); + + return $paymentRefund; + } + + $paymentRefund->lineAllocations ??= $refundRequest->lineAllocations; + + if (! $paymentRefund->refundTransactionId) { + $refundTransaction = $refundRequest->transaction->order + ->refunds() + ->whereNotIn('id', $existingRefundIds) + ->latest('id') + ->first(); + + if ($refundTransaction) { + $this->hydratePaymentRefund($paymentRefund, $refundTransaction); + } + } + + if (! $paymentRefund->success) { + RefundFailed::dispatch( + refundRequest: $refundRequest, + paymentRefund: $paymentRefund, + message: $paymentRefund->message, + meta: $authorization->meta, + ); + + return $paymentRefund; + } + + RefundCompleted::dispatch($refundRequest, $paymentRefund); + + return $paymentRefund; + } + + protected function hydratePaymentRefund(PaymentRefund $paymentRefund, Transaction $refundTransaction): void + { + $paymentRefund->refundTransactionId ??= $refundTransaction->id; + $paymentRefund->reference ??= $refundTransaction->reference; + $paymentRefund->status ??= $refundTransaction->status; + $paymentRefund->meta ??= $refundTransaction->meta?->getArrayCopy() ?: null; + } +} diff --git a/packages/core/src/Base/DataTransferObjects/PaymentRefund.php b/packages/core/src/Base/DataTransferObjects/PaymentRefund.php index fa5d19f954..b5d7c6d2ec 100644 --- a/packages/core/src/Base/DataTransferObjects/PaymentRefund.php +++ b/packages/core/src/Base/DataTransferObjects/PaymentRefund.php @@ -4,9 +4,18 @@ class PaymentRefund { + /** + * @param array|null $meta + * @param array>|null $lineAllocations + */ public function __construct( public bool $success = false, - public ?string $message = null + public ?string $message = null, + public ?int $refundTransactionId = null, + public ?string $reference = null, + public ?string $status = null, + public ?array $meta = null, + public ?array $lineAllocations = null, ) { // } diff --git a/packages/core/src/Base/DataTransferObjects/RefundAuthorizationResult.php b/packages/core/src/Base/DataTransferObjects/RefundAuthorizationResult.php new file mode 100644 index 0000000000..b93a991c6a --- /dev/null +++ b/packages/core/src/Base/DataTransferObjects/RefundAuthorizationResult.php @@ -0,0 +1,17 @@ + $meta + */ + public function __construct( + public bool $authorized = true, + public ?string $message = null, + public array $meta = [], + ) { + // + } +} diff --git a/packages/core/src/Base/DataTransferObjects/RefundRequest.php b/packages/core/src/Base/DataTransferObjects/RefundRequest.php new file mode 100644 index 0000000000..44eb6771d4 --- /dev/null +++ b/packages/core/src/Base/DataTransferObjects/RefundRequest.php @@ -0,0 +1,23 @@ + $meta + * @param array>|null $lineAllocations + */ + public function __construct( + public Transaction $transaction, + public int $amount, + public ?string $notes = null, + public ?int $actorId = null, + public array $meta = [], + public ?array $lineAllocations = null, + ) { + // + } +} diff --git a/packages/core/src/Base/OrderLineCapabilities.php b/packages/core/src/Base/OrderLineCapabilities.php new file mode 100644 index 0000000000..6236217be2 --- /dev/null +++ b/packages/core/src/Base/OrderLineCapabilities.php @@ -0,0 +1,44 @@ +order->isPlaced() + && in_array($orderLine->type, ['physical', 'digital'], true) + && $orderLine->total->value > 0; + } + + public function isCancellable(OrderLine $orderLine): bool + { + return $orderLine->order->isPlaced() + && $orderLine->type === 'digital' + && $orderLine->total->value > 0; + } + + public function requiresPhysicalReturn(OrderLine $orderLine): bool + { + return $orderLine->order->isPlaced() && $orderLine->type === 'physical'; + } + + public function createsEntitlement(OrderLine $orderLine): bool + { + return $orderLine->type === 'digital'; + } + + public function supportsEndOfTerm(OrderLine $orderLine): bool + { + return false; + } + + public function allowsAccountCredit(OrderLine $orderLine): bool + { + return $orderLine->order->isPlaced() + && in_array($orderLine->type, ['physical', 'digital'], true) + && $orderLine->total->value > 0; + } +} diff --git a/packages/core/src/Base/OrderLineCapabilitiesInterface.php b/packages/core/src/Base/OrderLineCapabilitiesInterface.php new file mode 100644 index 0000000000..72454ad4d4 --- /dev/null +++ b/packages/core/src/Base/OrderLineCapabilitiesInterface.php @@ -0,0 +1,20 @@ + $meta + */ + public function __construct( + public Order $order, + public PaymentAuthorize $paymentAuthorize, + public ?Transaction $transaction = null, + public array $meta = [], + ) { + // + } +} diff --git a/packages/core/src/Events/RefundCompleted.php b/packages/core/src/Events/RefundCompleted.php new file mode 100644 index 0000000000..3479388e4d --- /dev/null +++ b/packages/core/src/Events/RefundCompleted.php @@ -0,0 +1,21 @@ + $meta + */ + public function __construct( + public RefundRequest $refundRequest, + public ?PaymentRefund $paymentRefund = null, + public ?string $message = null, + public array $meta = [], + ) { + // + } +} diff --git a/packages/core/src/Events/RefundRequested.php b/packages/core/src/Events/RefundRequested.php new file mode 100644 index 0000000000..abb2d68b6e --- /dev/null +++ b/packages/core/src/Events/RefundRequested.php @@ -0,0 +1,19 @@ +make(OrderReferenceGenerator::class); }); + $this->app->singleton(OrderLineCapabilitiesInterface::class, function ($app) { + return $app->make(OrderLineCapabilities::class); + }); + $this->app->singleton(AttributeManifestInterface::class, function ($app) { return $app->make(AttributeManifest::class); }); @@ -197,6 +205,10 @@ public function register(): void return $app->make(PaymentManager::class); }); + $this->app->singleton(RefundAuthorizationInterface::class, function ($app) { + return $app->make(RefundAuthorization::class); + }); + $this->app->singleton(DiscountManagerInterface::class, function ($app) { return $app->make(DiscountManager::class); }); diff --git a/packages/core/src/PaymentTypes/AbstractPayment.php b/packages/core/src/PaymentTypes/AbstractPayment.php index b5d6022396..fd735ac7c0 100644 --- a/packages/core/src/PaymentTypes/AbstractPayment.php +++ b/packages/core/src/PaymentTypes/AbstractPayment.php @@ -2,13 +2,16 @@ namespace Lunar\PaymentTypes; +use Lunar\Base\DataTransferObjects\PaymentAuthorize; use Lunar\Base\DataTransferObjects\PaymentChecks; use Lunar\Base\PaymentTypeInterface; +use Lunar\Events\OrderPaid; use Lunar\Models\Cart; use Lunar\Models\Contracts\Cart as CartContract; use Lunar\Models\Contracts\Order as OrderContract; use Lunar\Models\Contracts\Transaction as TransactionContract; use Lunar\Models\Order; +use Lunar\Models\Transaction; abstract class AbstractPayment implements PaymentTypeInterface { @@ -80,4 +83,30 @@ public function getPaymentChecks(TransactionContract $transaction): PaymentCheck { return new PaymentChecks; } + + /** + * Dispatch the canonical post-purchase event when an order becomes paid. + * + * @param array $meta + */ + protected function dispatchOrderPaidEvent(PaymentAuthorize $paymentAuthorize, array $meta = []): void + { + if (! $paymentAuthorize->success || ! $this->order?->isPlaced()) { + return; + } + + /** @var Transaction|null $transaction */ + $transaction = $this->order->transactions() + ->whereSuccess(true) + ->whereIn('type', ['capture', 'intent']) + ->latest('id') + ->first(); + + OrderPaid::dispatch( + order: $this->order, + paymentAuthorize: $paymentAuthorize, + transaction: $transaction, + meta: $meta, + ); + } } diff --git a/packages/core/src/PaymentTypes/OfflinePayment.php b/packages/core/src/PaymentTypes/OfflinePayment.php index 63e817a63d..a3548f2b4e 100644 --- a/packages/core/src/PaymentTypes/OfflinePayment.php +++ b/packages/core/src/PaymentTypes/OfflinePayment.php @@ -40,6 +40,7 @@ public function authorize(): ?PaymentAuthorize ); PaymentAttemptEvent::dispatch($response); + $this->dispatchOrderPaidEvent($response); return $response; } diff --git a/packages/opayo/src/OpayoPaymentType.php b/packages/opayo/src/OpayoPaymentType.php index 415e2be839..eef1a83978 100644 --- a/packages/opayo/src/OpayoPaymentType.php +++ b/packages/opayo/src/OpayoPaymentType.php @@ -142,6 +142,7 @@ public function authorize(): PaymentAuthorize|ThreeDSecureResponse ); PaymentAttemptEvent::dispatch($response); + $this->dispatchOrderPaidEvent($response); return $response; } @@ -210,7 +211,7 @@ public function refund(TransactionContract $transaction, int $amount = 0, $notes ); } - $transaction->order->transactions()->create([ + $refundTransaction = $transaction->order->transactions()->create([ 'parent_transaction_id' => $transaction->id, 'success' => true, 'type' => 'refund', @@ -225,7 +226,11 @@ public function refund(TransactionContract $transaction, int $amount = 0, $notes ]); return new PaymentRefund( - success: true + success: true, + refundTransactionId: $refundTransaction->id, + reference: $refundTransaction->reference, + status: $refundTransaction->status, + meta: $refundTransaction->meta?->getArrayCopy() ?: null, ); } @@ -338,6 +343,7 @@ public function threedsecure() ); PaymentAttemptEvent::dispatch($response); + $this->dispatchOrderPaidEvent($response); return $response; } diff --git a/packages/paypal/src/PaypalPaymentType.php b/packages/paypal/src/PaypalPaymentType.php index d2185cad77..d54c703167 100644 --- a/packages/paypal/src/PaypalPaymentType.php +++ b/packages/paypal/src/PaypalPaymentType.php @@ -114,6 +114,7 @@ public function authorize(): PaymentAuthorize ); PaymentAttemptEvent::dispatch($response); + $this->dispatchOrderPaidEvent($response); return $response; } @@ -158,7 +159,7 @@ public function refund(TransactionContract $transaction, int $amount = 0, $notes $currencyCode ); - $transaction->order->transactions()->create([ + $refundTransaction = $transaction->order->transactions()->create([ 'success' => true, 'type' => 'refund', 'driver' => 'paypal', @@ -171,7 +172,11 @@ public function refund(TransactionContract $transaction, int $amount = 0, $notes ]); return new PaymentRefund( - success: true + success: true, + refundTransactionId: $refundTransaction->id, + reference: $refundTransaction->reference, + status: $refundTransaction->status, + meta: $refundTransaction->meta?->getArrayCopy() ?: null, ); } catch (HttpClientException $e) { return new PaymentRefund( diff --git a/packages/stripe/src/StripePaymentType.php b/packages/stripe/src/StripePaymentType.php index b217b48673..aab2c41e4f 100644 --- a/packages/stripe/src/StripePaymentType.php +++ b/packages/stripe/src/StripePaymentType.php @@ -152,6 +152,7 @@ final public function authorize(): ?PaymentAuthorize ); PaymentAttemptEvent::dispatch($response); + $this->dispatchOrderPaidEvent($response); $paymentIntentModel->processed_at = now(); @@ -216,7 +217,7 @@ public function refund(TransactionContract $transaction, int $amount = 0, $notes ); } - $transaction->order->transactions()->create([ + $refundTransaction = $transaction->order->transactions()->create([ 'success' => $refund->status != 'failed', 'type' => 'refund', 'driver' => 'stripe', @@ -229,7 +230,11 @@ public function refund(TransactionContract $transaction, int $amount = 0, $notes ]); return new PaymentRefund( - success: true + success: true, + refundTransactionId: $refundTransaction->id, + reference: $refundTransaction->reference, + status: $refundTransaction->status, + meta: $refundTransaction->meta?->getArrayCopy() ?: null, ); } diff --git a/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php b/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php index 5db2f5dcf2..5025e0fc37 100644 --- a/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php +++ b/tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php @@ -1,13 +1,23 @@ 'testing-refund', + 'authorized' => 'paid', + ]); + + Payments::extend('testing-refund', fn () => new class extends AbstractPayment + { + public function authorize(): ?PaymentAuthorize + { + return new PaymentAuthorize(success: true); + } + + public function refund(Lunar\Models\Contracts\Transaction $transaction, int $amount = 0, $notes = null): PaymentRefund + { + $refundTransaction = $transaction->order->transactions()->create([ + 'parent_transaction_id' => $transaction->id, + 'success' => true, + 'type' => 'refund', + 'driver' => 'testing-refund', + 'amount' => $amount, + 'reference' => 'admin-refund-'.$amount, + 'status' => 'refunded', + 'notes' => $notes, + 'card_type' => $transaction->card_type, + 'last_four' => $transaction->last_four, + ]); + + return new PaymentRefund( + success: true, + refundTransactionId: $refundTransaction->id, + reference: $refundTransaction->reference, + status: $refundTransaction->status, + ); + } + + public function capture(Lunar\Models\Contracts\Transaction $transaction, $amount = 0): PaymentCapture + { + return new PaymentCapture(success: true); + } + }); }); it('can render order manage page', function () { @@ -161,3 +213,94 @@ expect($this->order->refresh()) ->status->toBe($status); }); + +it('uses refund orchestration for the header refund action', function () { + $capture = Transaction::factory()->create([ + 'order_id' => $this->order->id, + 'driver' => 'testing-refund', + 'type' => 'capture', + 'success' => true, + 'amount' => 1500, + 'reference' => 'capture-header', + 'status' => 'captured', + 'card_type' => 'visa', + 'last_four' => '4242', + ]); + + Event::fake([ + RefundRequested::class, + RefundCompleted::class, + ]); + + Livewire::test(ManageOrder::class, [ + 'record' => $this->order->getRouteKey(), + ]) + ->callAction('refund', [ + 'transaction' => $capture->id, + 'amount' => '15.00', + 'notes' => 'Header refund', + 'confirm' => true, + ]) + ->assertNotified(); + + Event::assertDispatched(RefundRequested::class, function (RefundRequested $event) use ($capture) { + return $event->refundRequest->transaction->is($capture) + && $event->refundRequest->amount === 1500 + && $event->refundRequest->lineAllocations === null; + }); + + Event::assertDispatched(RefundCompleted::class, function (RefundCompleted $event) { + return $event->paymentRefund->success + && $event->paymentRefund->refundTransactionId !== null; + }); +}); + +it('uses refund orchestration for the bulk line refund action', function () { + $capture = Transaction::factory()->create([ + 'order_id' => $this->order->id, + 'driver' => 'testing-refund', + 'type' => 'capture', + 'success' => true, + 'amount' => 3000, + 'reference' => 'capture-bulk', + 'status' => 'captured', + 'card_type' => 'visa', + 'last_four' => '4242', + ]); + + $lines = OrderLine::factory(2)->create([ + 'order_id' => $this->order->id, + 'type' => 'physical', + 'total' => 1000, + 'sub_total' => 800, + 'tax_total' => 200, + 'discount_total' => 0, + ]); + + Event::fake([ + RefundRequested::class, + RefundCompleted::class, + ]); + + Livewire::test(OrderItemsTable::class, [ + 'record' => $this->order, + ]) + ->selectTableRecords($lines->pluck('id')->all()) + ->callAction(TestAction::make('bulk_refund')->table()->bulk(), data: [ + 'transaction' => $capture->id, + 'amount' => '20.00', + 'notes' => 'Bulk refund', + 'confirm' => true, + ]) + ->assertNotified(); + + Event::assertDispatched(RefundRequested::class, function (RefundRequested $event) use ($capture, $lines) { + $allocations = collect($event->refundRequest->lineAllocations); + + return $event->refundRequest->transaction->is($capture) + && $event->refundRequest->amount === 2000 + && $allocations->pluck('order_line_id')->all() === $lines->pluck('id')->all(); + }); + + Event::assertDispatched(RefundCompleted::class); +}); diff --git a/tests/core/Unit/Actions/Orders/RefundTransactionTest.php b/tests/core/Unit/Actions/Orders/RefundTransactionTest.php new file mode 100644 index 0000000000..06c4b5c50c --- /dev/null +++ b/tests/core/Unit/Actions/Orders/RefundTransactionTest.php @@ -0,0 +1,297 @@ +create([ + 'default' => true, + 'code' => 'en', + ]); + + Currency::factory()->create([ + 'default' => true, + 'code' => 'USD', + 'decimal_places' => 2, + 'exchange_rate' => 1, + ]); + + Config::set('lunar.payments.types.testing-refund', [ + 'driver' => 'testing-refund', + 'authorized' => 'paid', + ]); + + Payments::extend('testing-refund', fn () => new class extends AbstractPayment + { + public function authorize(): ?PaymentAuthorize + { + return new PaymentAuthorize(success: true); + } + + public function refund(Lunar\Models\Contracts\Transaction $transaction, int $amount = 0, $notes = null): PaymentRefund + { + if (app()->bound('refund-sequence')) { + app('refund-sequence')->push('driver'); + } + + if ($notes === 'throw-exception') { + throw new RuntimeException('Gateway timeout'); + } + + if ($notes === 'driver-fail') { + return new PaymentRefund( + success: false, + message: 'Driver refund failed', + ); + } + + $refundTransaction = $transaction->order->transactions()->create([ + 'parent_transaction_id' => $transaction->id, + 'success' => true, + 'type' => 'refund', + 'driver' => 'testing-refund', + 'amount' => $amount, + 'reference' => 'refund-'.$amount, + 'status' => 'refunded', + 'notes' => $notes, + 'card_type' => $transaction->card_type, + 'last_four' => $transaction->last_four, + ]); + + if ($notes === 'hydrate-me') { + return new PaymentRefund(success: true); + } + + return new PaymentRefund( + success: true, + refundTransactionId: $refundTransaction->id, + reference: $refundTransaction->reference, + status: $refundTransaction->status, + ); + } + + public function capture(Lunar\Models\Contracts\Transaction $transaction, $amount = 0): PaymentCapture + { + return new PaymentCapture(success: true); + } + }); +}); + +function makeCaptureTransaction(string $driver = 'testing-refund'): Transaction +{ + $currency = Currency::getDefault(); + + $order = Order::factory()->create([ + 'currency_code' => $currency->code, + 'placed_at' => now(), + 'total' => 1500, + ]); + + return Transaction::factory()->create([ + 'order_id' => $order->id, + 'driver' => $driver, + 'type' => 'capture', + 'success' => true, + 'amount' => 1500, + 'reference' => 'capture-1500', + 'status' => 'captured', + 'card_type' => 'visa', + 'last_four' => '4242', + ]); +} + +it('dispatches refund requested and completed events for an allowed refund', function () { + $transaction = makeCaptureTransaction(); + $lineAllocations = [ + [ + 'order_line_id' => 10, + 'quantity' => 1, + 'amount' => 500, + ], + ]; + + Event::fake([ + RefundRequested::class, + RefundCompleted::class, + RefundFailed::class, + ]); + + $response = app(RefundTransaction::class)->execute( + new RefundRequest( + transaction: $transaction, + amount: 500, + notes: 'hydrate-me', + actorId: 99, + lineAllocations: $lineAllocations, + ) + ); + + expect($response->success)->toBeTrue() + ->and($response->refundTransactionId)->not->toBeNull() + ->and($response->reference)->toBe('refund-500') + ->and($response->status)->toBe('refunded') + ->and($response->lineAllocations)->toBe($lineAllocations); + + Event::assertDispatched(RefundRequested::class, function (RefundRequested $event) use ($transaction, $lineAllocations) { + return $event->refundRequest->transaction->is($transaction) + && $event->refundRequest->lineAllocations === $lineAllocations; + }); + + Event::assertDispatched(RefundCompleted::class, function (RefundCompleted $event) use ($response, $lineAllocations) { + return $event->paymentRefund->refundTransactionId === $response->refundTransactionId + && $event->paymentRefund->lineAllocations === $lineAllocations; + }); + + Event::assertNotDispatched(RefundFailed::class); +}); + +it('dispatches refund failed when authorization denies the refund', function () { + $transaction = makeCaptureTransaction(); + + app()->bind(RefundAuthorizationInterface::class, fn () => new class implements RefundAuthorizationInterface + { + public function authorize(RefundRequest $refundRequest): RefundAuthorizationResult + { + return new RefundAuthorizationResult( + authorized: false, + message: 'An approved RMA is required', + meta: ['reason' => 'missing-rma'], + ); + } + }); + + Event::fake([ + RefundRequested::class, + RefundCompleted::class, + RefundFailed::class, + ]); + + $response = app(RefundTransaction::class)->execute( + new RefundRequest( + transaction: $transaction, + amount: 500, + ) + ); + + expect($response->success)->toBeFalse() + ->and($response->message)->toBe('An approved RMA is required'); + + Event::assertNotDispatched(RefundRequested::class); + Event::assertNotDispatched(RefundCompleted::class); + Event::assertDispatched(RefundFailed::class, function (RefundFailed $event) use ($transaction) { + return $event->refundRequest->transaction->is($transaction) + && $event->message === 'An approved RMA is required' + && $event->meta === ['reason' => 'missing-rma']; + }); + + expect($transaction->order->refresh()->refunds)->toHaveCount(0); +}); + +it('dispatches refund failed when the driver rejects the refund', function () { + $transaction = makeCaptureTransaction(); + + Event::fake([ + RefundRequested::class, + RefundCompleted::class, + RefundFailed::class, + ]); + + $response = app(RefundTransaction::class)->execute( + new RefundRequest( + transaction: $transaction, + amount: 500, + notes: 'driver-fail', + ) + ); + + expect($response->success)->toBeFalse() + ->and($response->message)->toBe('Driver refund failed'); + + Event::assertDispatched(RefundRequested::class); + Event::assertNotDispatched(RefundCompleted::class); + Event::assertDispatched(RefundFailed::class, function (RefundFailed $event) { + return $event->message === 'Driver refund failed'; + }); +}); + +it('dispatches refund requested before calling the payment driver', function () { + $transaction = makeCaptureTransaction(); + + app()->instance('refund-sequence', collect()); + + Event::listen(RefundRequested::class, function () { + app('refund-sequence')->push('requested'); + }); + + app(RefundTransaction::class)->execute( + new RefundRequest( + transaction: $transaction, + amount: 500, + ) + ); + + expect(app('refund-sequence')->all())->toBe([ + 'requested', + 'driver', + ]); +}); + +it('dispatches refund failed when the driver throws an exception', function () { + $transaction = makeCaptureTransaction(); + $lineAllocations = [ + [ + 'order_line_id' => 11, + 'quantity' => 1, + 'amount' => 500, + ], + ]; + + Event::fake([ + RefundRequested::class, + RefundCompleted::class, + RefundFailed::class, + ]); + + $response = app(RefundTransaction::class)->execute( + new RefundRequest( + transaction: $transaction, + amount: 500, + notes: 'throw-exception', + lineAllocations: $lineAllocations, + ) + ); + + expect($response->success)->toBeFalse() + ->and($response->message)->toBe('Gateway timeout') + ->and($response->lineAllocations)->toBe($lineAllocations); + + Event::assertDispatched(RefundRequested::class); + Event::assertNotDispatched(RefundCompleted::class); + Event::assertDispatched(RefundFailed::class, function (RefundFailed $event) use ($lineAllocations) { + return $event->message === 'Gateway timeout' + && $event->paymentRefund?->lineAllocations === $lineAllocations; + }); + + expect($transaction->order->refresh()->refunds)->toHaveCount(0); +}); diff --git a/tests/core/Unit/Base/OrderLineCapabilitiesTest.php b/tests/core/Unit/Base/OrderLineCapabilitiesTest.php new file mode 100644 index 0000000000..b72592c768 --- /dev/null +++ b/tests/core/Unit/Base/OrderLineCapabilitiesTest.php @@ -0,0 +1,140 @@ +create([ + 'default' => true, + 'code' => 'en', + ]); + + Currency::factory()->create([ + 'default' => true, + 'code' => 'USD', + 'decimal_places' => 2, + 'exchange_rate' => 1, + ]); +}); + +it('returns conservative default order line capabilities', function () { + $currency = Currency::getDefault(); + $placedOrder = Order::factory()->create([ + 'currency_code' => $currency->code, + 'placed_at' => now(), + ]); + + $physicalLine = OrderLine::factory()->create([ + 'order_id' => $placedOrder->id, + 'type' => 'physical', + 'total' => 1000, + ]); + + $digitalLine = OrderLine::factory()->create([ + 'order_id' => $placedOrder->id, + 'type' => 'digital', + 'total' => 800, + ]); + + $shippingLine = OrderLine::factory()->create([ + 'order_id' => $placedOrder->id, + 'type' => 'shipping', + 'total' => 300, + ]); + + $resolver = app(OrderLineCapabilitiesInterface::class); + + expect($resolver->isRefundable($physicalLine))->toBeTrue() + ->and($resolver->isCancellable($physicalLine))->toBeFalse() + ->and($resolver->requiresPhysicalReturn($physicalLine))->toBeTrue() + ->and($resolver->createsEntitlement($physicalLine))->toBeFalse() + ->and($resolver->supportsEndOfTerm($physicalLine))->toBeFalse() + ->and($resolver->allowsAccountCredit($physicalLine))->toBeTrue(); + + expect($resolver->isRefundable($digitalLine))->toBeTrue() + ->and($resolver->isCancellable($digitalLine))->toBeTrue() + ->and($resolver->requiresPhysicalReturn($digitalLine))->toBeFalse() + ->and($resolver->createsEntitlement($digitalLine))->toBeTrue() + ->and($resolver->supportsEndOfTerm($digitalLine))->toBeFalse() + ->and($resolver->allowsAccountCredit($digitalLine))->toBeTrue(); + + expect($resolver->isRefundable($shippingLine))->toBeFalse() + ->and($resolver->isCancellable($shippingLine))->toBeFalse() + ->and($resolver->requiresPhysicalReturn($shippingLine))->toBeFalse() + ->and($resolver->createsEntitlement($shippingLine))->toBeFalse() + ->and($resolver->supportsEndOfTerm($shippingLine))->toBeFalse() + ->and($resolver->allowsAccountCredit($shippingLine))->toBeFalse(); +}); + +it('allows packages to replace the capability resolver binding', function () { + $line = OrderLine::factory()->create(); + + app()->bind(OrderLineCapabilitiesInterface::class, fn () => new class implements OrderLineCapabilitiesInterface + { + public function isRefundable(OrderLine $orderLine): bool + { + return false; + } + + public function isCancellable(OrderLine $orderLine): bool + { + return true; + } + + public function requiresPhysicalReturn(OrderLine $orderLine): bool + { + return false; + } + + public function createsEntitlement(OrderLine $orderLine): bool + { + return true; + } + + public function supportsEndOfTerm(OrderLine $orderLine): bool + { + return true; + } + + public function allowsAccountCredit(OrderLine $orderLine): bool + { + return false; + } + }); + + $resolver = app(OrderLineCapabilitiesInterface::class); + + expect($resolver->isRefundable($line))->toBeFalse() + ->and($resolver->isCancellable($line))->toBeTrue() + ->and($resolver->createsEntitlement($line))->toBeTrue() + ->and($resolver->supportsEndOfTerm($line))->toBeTrue() + ->and($resolver->allowsAccountCredit($line))->toBeFalse(); +}); + +it('does not expose refundable or return capabilities for unplaced lines', function () { + $draftOrder = Order::factory()->create([ + 'placed_at' => null, + ]); + + $line = OrderLine::factory()->create([ + 'order_id' => $draftOrder->id, + 'type' => 'physical', + 'total' => 1000, + ]); + + $resolver = app(OrderLineCapabilitiesInterface::class); + + expect($resolver->isRefundable($line))->toBeFalse() + ->and($resolver->isCancellable($line))->toBeFalse() + ->and($resolver->requiresPhysicalReturn($line))->toBeFalse() + ->and($resolver->createsEntitlement($line))->toBeFalse() + ->and($resolver->allowsAccountCredit($line))->toBeFalse(); +}); diff --git a/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php b/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php index a8b33461b1..dac2880706 100644 --- a/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php +++ b/tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php @@ -2,7 +2,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\Event; use Lunar\Base\DataTransferObjects\PaymentAuthorize; +use Lunar\Events\OrderPaid; use Lunar\Facades\Payments; use Lunar\Models\Cart; use Lunar\Models\CartAddress; @@ -124,3 +126,42 @@ expect($meta['foo'])->toEqual('bar'); }); + +test('dispatches order paid when offline payment authorizes successfully', function () { + $cart = Cart::factory()->create(); + + Config::set('lunar.payments.types.offline', [ + 'authorized' => 'offline-payment', + ]); + + CartAddress::factory()->create([ + 'cart_id' => $cart->id, + 'type' => 'billing', + 'country_id' => Country::factory(), + 'first_name' => 'Santa', + 'line_one' => '123 Elf Road', + 'city' => 'Lapland', + 'postcode' => 'BILL', + ]); + + CartAddress::factory()->create([ + 'cart_id' => $cart->id, + 'type' => 'shipping', + 'country_id' => Country::factory(), + 'first_name' => 'Santa', + 'line_one' => '123 Elf Road', + 'city' => 'Lapland', + 'postcode' => 'SHIPP', + ]); + + Event::fake([ + OrderPaid::class, + ]); + + Payments::driver('offline')->cart($cart->refresh())->authorize(); + + Event::assertDispatched(OrderPaid::class, function (OrderPaid $event) use ($cart) { + return $event->order->is($cart->refresh()->completedOrder) + && $event->paymentAuthorize->success; + }); +}); diff --git a/tests/opayo/Feature/OpayoPaymentTypeTest.php b/tests/opayo/Feature/OpayoPaymentTypeTest.php index 363bd7dd59..942b36a0a8 100644 --- a/tests/opayo/Feature/OpayoPaymentTypeTest.php +++ b/tests/opayo/Feature/OpayoPaymentTypeTest.php @@ -1,6 +1,10 @@ fn (Request $request) => Http::response($fixture), + 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions/DB79BA2D-05DA-5B85-D188-1293D16BBAC7' => fn () => Http::response($fixture), + ]); + + Event::fake([ + OrderPaid::class, + ]); $response = (new OpayoPaymentType)->cart($cart)->withData([ 'merchant_key' => 'SUCCESS', @@ -30,19 +44,20 @@ ->and($order->status)->toBe('payment-received') ->and($order->placed_at)->not->toBeNull(); - assertDatabaseHas(Transaction::class, [ - 'success' => true, - 'type' => 'capture', - 'driver' => 'opayo', - 'reference' => 'DB79BA2D-05DA-5B85-D188-1293D16BBAC7', - 'status' => 'Ok', - 'card_type' => 'Visa', - 'last_four' => '1111', - ]); + Event::assertDispatched(OrderPaid::class, function (OrderPaid $event) use ($order) { + return $event->order->is($order) + && $event->paymentAuthorize->success; + }); }); it('can handle a failed payment', function () { $cart = buildCart(); + $fixture = json_decode(file_get_contents(__DIR__.'/../Opayo/transaction_not_authed.json'), true); + + Http::fake([ + 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions' => fn (Request $request) => Http::response($fixture), + 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions/DB79BA2D-05DA-5B85-D188-1293D16BBAC7' => fn () => Http::response($fixture), + ]); $response = (new OpayoPaymentType)->cart($cart)->withData([ 'merchant_key' => 'FAILED', @@ -57,15 +72,6 @@ ->and($cart->currentDraftOrder()) ->toBeInstanceOf(Order::class); - assertDatabaseHas(Transaction::class, [ - 'success' => false, - 'type' => 'capture', - 'driver' => 'opayo', - 'reference' => 'DB79BA2D-05DA-5B85-D188-1293D16BBAC7', - 'status' => 'NotAuthed', - 'card_type' => 'Visa', - 'last_four' => '1111', - ]); }); it('can handle a 3DSv2 response', function () { diff --git a/tests/opayo/TestCase.php b/tests/opayo/TestCase.php index 98ffed5886..3fcb35791c 100644 --- a/tests/opayo/TestCase.php +++ b/tests/opayo/TestCase.php @@ -22,6 +22,8 @@ protected function setUp(): void Http::preventStrayRequests(); + $lastTransactionFixture = null; + $getResponse = fn ($file) => Http::response( json_decode( file_get_contents( @@ -31,12 +33,21 @@ protected function setUp(): void ); Http::fake([ - 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions' => fn (Request $request) => match ($request->data()['paymentMethod']['card']['merchantSessionKey']) { - 'SUCCESS' => $getResponse('transaction_201'), - 'FAILED' => $getResponse('transaction_not_authed'), - 'SUCCESS_3DSV2' => $getResponse('transaction_202'), - default => Http::response('ok'), + 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions' => function (Request $request) use (&$lastTransactionFixture, $getResponse) { + $lastTransactionFixture = match ($request->data()['paymentMethod']['card']['merchantSessionKey']) { + 'SUCCESS' => 'transaction_201', + 'FAILED' => 'transaction_not_authed', + 'SUCCESS_3DSV2' => 'transaction_202', + default => null, + }; + + return $lastTransactionFixture + ? $getResponse($lastTransactionFixture) + : Http::response('ok'); }, + 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions/DB79BA2D-05DA-5B85-D188-1293D16BBAC7' => fn () => $lastTransactionFixture + ? $getResponse($lastTransactionFixture) + : Http::response('ok'), 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions/3DSV2_SUCCESS/3d-secure-challenge' => fn (Request $request) => $getResponse('3dsv2_successful'), 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions/3DSV2_FAILURE/3d-secure-challenge' => fn (Request $request) => $getResponse('3dsv2_not_authed'), 'https://sandbox.opayo.eu.elavon.com/api/v1/transactions/3DSV2_SUCCESS' => fn (Request $request) => $getResponse('3dsv2_successful'), diff --git a/tests/paypal/TestCase.php b/tests/paypal/TestCase.php new file mode 100644 index 0000000000..e93f361833 --- /dev/null +++ b/tests/paypal/TestCase.php @@ -0,0 +1,47 @@ +disableLogging(); + } + + protected function getPackageProviders($app) + { + return [ + LunarServiceProvider::class, + PaypalServiceProvider::class, + BlinkServiceProvider::class, + MediaLibraryServiceProvider::class, + ActivitylogServiceProvider::class, + NestedSetServiceProvider::class, + ]; + } + + protected function getEnvironmentSetUp($app) + { + $this->replaceModelsForTesting(); + } + + protected function defineDatabaseMigrations() + { + $this->loadLaravelMigrations(); + } +} diff --git a/tests/paypal/Unit/PaypalPaymentTypeTest.php b/tests/paypal/Unit/PaypalPaymentTypeTest.php new file mode 100644 index 0000000000..871dfae9bb --- /dev/null +++ b/tests/paypal/Unit/PaypalPaymentTypeTest.php @@ -0,0 +1,126 @@ + 'APPROVED', + 'purchase_units' => [], + ]; + } + + public function capture(string $orderId): array + { + return [ + 'status' => 'COMPLETED', + 'purchase_units' => [ + [ + 'payments' => [ + 'captures' => [ + [ + 'status' => 'COMPLETED', + 'amount' => ['value' => '6.98'], + 'id' => 'CAPTURE-123', + 'create_time' => '2024-01-01T00:00:00Z', + ], + ], + ], + ], + ], + ]; + } + + public function refund($transactionId, string $amount, string $currencyCode): array + { + return [ + 'id' => 'REFUND-123', + 'status' => 'COMPLETED', + ]; + } + }); +}); + +it('dispatches order paid when paypal authorization places the order', function () { + $cart = buildCart(); + + Event::fake([ + OrderPaid::class, + ]); + + $response = (new PaypalPaymentType)->cart($cart)->withData([ + 'paypal_order_id' => 'ORDER-123', + 'status' => 'payment-received', + ])->authorize(); + + $order = $cart->refresh()->completedOrder; + + expect($response->success)->toBeTrue() + ->and($order)->not->toBeNull() + ->and($order->status)->toBe('payment-received') + ->and($order->placed_at)->not->toBeNull(); + + assertDatabaseHas((new Transaction)->getTable(), [ + 'order_id' => $order->id, + 'type' => 'capture', + 'driver' => 'paypal', + 'reference' => 'CAPTURE-123', + 'status' => 'COMPLETED', + ]); + + Event::assertDispatched(OrderPaid::class, function (OrderPaid $event) use ($order) { + return $event->order->is($order) + && $event->paymentAuthorize->success + && $event->transaction?->reference === 'CAPTURE-123'; + }); +}); + +it('returns the expanded refund dto for paypal refunds', function () { + $cart = buildCart(); + + (new PaypalPaymentType)->cart($cart)->withData([ + 'paypal_order_id' => 'ORDER-123', + 'status' => 'payment-received', + ])->authorize(); + + $order = $cart->refresh()->completedOrder; + $capture = $order->transactions()->where('type', 'capture')->firstOrFail(); + + $response = (new PaypalPaymentType)->order($order)->refund( + $capture, + 500, + 'Return approved', + ); + + expect($response->success)->toBeTrue() + ->and($response->refundTransactionId)->not->toBeNull() + ->and($response->reference)->toBe('REFUND-123') + ->and($response->status)->toBe('COMPLETED') + ->and($response->meta)->toBeNull(); + + assertDatabaseHas((new Transaction)->getTable(), [ + 'id' => $response->refundTransactionId, + 'order_id' => $order->id, + 'type' => 'refund', + 'driver' => 'paypal', + 'amount' => 500, + 'reference' => 'REFUND-123', + 'status' => 'COMPLETED', + 'notes' => 'Return approved', + ]); +}); diff --git a/tests/stripe/Unit/StripePaymentTypeTest.php b/tests/stripe/Unit/StripePaymentTypeTest.php index be8b9df6c1..59cf9d225c 100644 --- a/tests/stripe/Unit/StripePaymentTypeTest.php +++ b/tests/stripe/Unit/StripePaymentTypeTest.php @@ -1,6 +1,8 @@ cart($cart)->withData([ 'payment_intent' => 'PI_CAPTURE', ])->authorize(); @@ -29,6 +35,12 @@ 'order_id' => $cart->refresh()->completedOrder->id, 'type' => 'capture', ]); + + Event::assertDispatched(OrderPaid::class, function (OrderPaid $event) use ($cart) { + return $event->order->is($cart->refresh()->completedOrder) + && $event->paymentAuthorize->success + && $event->transaction?->type === 'capture'; + }); })->group('this'); it('can handle failed payments', function () {