From 61008457f1a894bf8b39ea69b58cd169c4099e9c Mon Sep 17 00:00:00 2001 From: Mitch Schwenk Date: Sat, 11 Apr 2026 11:23:20 -0700 Subject: [PATCH 1/2] Preliminary release of adding to hook surface needed to unblock reusable returns/RMA integrations in Lunar v1.x - add OrderPaid plus canonical refund orchestration via RefundTransaction - add pre-refund authorization DTOs/contracts and refund lifecycle events - expand PaymentRefund with refund transaction context and allocation passthrough - add OrderLineCapabilitiesInterface with the default conservative resolver - route admin refund actions through the orchestration path - document the new refund and order hook surface in the Lunar docs Test coverage: - refund allowed, denied, driver-failed, and thrown-exception paths - RefundRequested ordering before the driver call - RefundCompleted and RefundFailed dispatch behavior - allocation passthrough and refund transaction hydration - default and override order-line capability behavior, including unplaced lines - admin header and bulk refund flows - OrderPaid coverage for Offline, Stripe, Opayo, and Paypal - Paypal expanded PaymentRefund coverage Verified with: - vendor/bin/pint --dirty --format agent - vendor/bin/pest tests/core/Unit/Actions/Orders/RefundTransactionTest.php tests/core/Unit/Base/OrderLineCapabilitiesTest.php tests/core/Unit/PaymentTypes/OfflinePaymentTypeTest.php tests/stripe/Unit/StripePaymentTypeTest.php tests/opayo/Feature/OpayoPaymentTypeTest.php tests/admin/Feature/Filament/Resources/OrderResource/Pages/ManageOrderTest.php tests/paypal/Unit/PaypalPaymentTypeTest.php Result: - 34 tests passed - 207 assertions --- composer.json | 4 +- .../AttributesRelationManager.php | 10 +- .../Pages/Components/OrderItemsTable.php | 21 +- .../OrderResource/Pages/ManageOrder.php | 11 +- .../src/Support/Facades/AttributeData.php | 1 + .../src/Support/FieldTypes/BaseFieldType.php | 9 + .../admin/src/Support/FieldTypes/Dropdown.php | 32 ++ .../admin/src/Support/Forms/AttributeData.php | 11 + .../src/Support/Pages/BaseEditRecord.php | 26 +- .../src/Actions/Orders/RefundTransaction.php | 109 +++++++ .../DataTransferObjects/PaymentRefund.php | 11 +- .../RefundAuthorizationResult.php | 17 + .../DataTransferObjects/RefundRequest.php | 23 ++ .../core/src/Base/OrderLineCapabilities.php | 44 +++ .../Base/OrderLineCapabilitiesInterface.php | 20 ++ .../core/src/Base/RefundAuthorization.php | 16 + .../src/Base/RefundAuthorizationInterface.php | 11 + packages/core/src/Events/OrderPaid.php | 27 ++ packages/core/src/Events/RefundCompleted.php | 21 ++ packages/core/src/Events/RefundFailed.php | 26 ++ packages/core/src/Events/RefundRequested.php | 19 ++ packages/core/src/FieldTypes/ListField.php | 2 +- packages/core/src/LunarServiceProvider.php | 12 + .../core/src/PaymentTypes/AbstractPayment.php | 29 ++ .../core/src/PaymentTypes/OfflinePayment.php | 1 + packages/opayo/src/OpayoPaymentType.php | 10 +- packages/paypal/src/PaypalPaymentType.php | 9 +- packages/stripe/src/StripePaymentType.php | 9 +- .../AttributesRelationManagerTest.php | 35 +++ .../OrderResource/Pages/ManageOrderTest.php | 143 +++++++++ .../ProductResource/Pages/EditProductTest.php | 145 +++++++++ .../Unit/Support/Forms/AttributeDataTest.php | 1 + .../Actions/Orders/RefundTransactionTest.php | 297 ++++++++++++++++++ .../Unit/Base/OrderLineCapabilitiesTest.php | 140 +++++++++ tests/core/Unit/FieldTypes/ListFieldTest.php | 12 + .../PaymentTypes/OfflinePaymentTypeTest.php | 41 +++ tests/opayo/Feature/OpayoPaymentTypeTest.php | 42 +-- tests/opayo/TestCase.php | 21 +- tests/paypal/TestCase.php | 47 +++ tests/paypal/Unit/PaypalPaymentTypeTest.php | 126 ++++++++ tests/stripe/Unit/StripePaymentTypeTest.php | 12 + 41 files changed, 1565 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/Actions/Orders/RefundTransaction.php create mode 100644 packages/core/src/Base/DataTransferObjects/RefundAuthorizationResult.php create mode 100644 packages/core/src/Base/DataTransferObjects/RefundRequest.php create mode 100644 packages/core/src/Base/OrderLineCapabilities.php create mode 100644 packages/core/src/Base/OrderLineCapabilitiesInterface.php create mode 100644 packages/core/src/Base/RefundAuthorization.php create mode 100644 packages/core/src/Base/RefundAuthorizationInterface.php create mode 100644 packages/core/src/Events/OrderPaid.php create mode 100644 packages/core/src/Events/RefundCompleted.php create mode 100644 packages/core/src/Events/RefundFailed.php create mode 100644 packages/core/src/Events/RefundRequested.php create mode 100644 tests/core/Unit/Actions/Orders/RefundTransactionTest.php create mode 100644 tests/core/Unit/Base/OrderLineCapabilitiesTest.php create mode 100644 tests/paypal/TestCase.php create mode 100644 tests/paypal/Unit/PaypalPaymentTypeTest.php diff --git a/composer.json b/composer.json index 034fd1916d..9b83a587d9 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "typesense/typesense-php": "^4.9" }, "require-dev": { -"filament/upgrade": "^4.0", + "filament/upgrade": "^4.0", "larastan/larastan": "^3.0", "laravel/pint": "1.29.0", "mockery/mockery": "^1.6.9", @@ -88,7 +88,7 @@ } }, "extra": { -"lunar": { + "lunar": { "name": [ "Table Rate Shipping", "Opayo Payments", diff --git a/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php b/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php index 5a90dc6c2c..2d7872a8f1 100644 --- a/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php +++ b/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php @@ -154,7 +154,15 @@ public function getDefaultTable(Table $table): Table }), ]) ->recordActions([ - EditAction::make(), + EditAction::make() + ->mutateRecordDataUsing(function (array $data): array { + $data['configuration'] = AttributeData::mutateConfigurationForForm( + $data['type'] ?? null, + $data['configuration'] ?? [], + ); + + return $data; + }), DeleteAction::make(), ]) ->toolbarActions([ 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/admin/src/Support/Facades/AttributeData.php b/packages/admin/src/Support/Facades/AttributeData.php index 26c0b75a15..6c0ce93e8d 100644 --- a/packages/admin/src/Support/Facades/AttributeData.php +++ b/packages/admin/src/Support/Facades/AttributeData.php @@ -11,6 +11,7 @@ * @method static Component getFilamentComponent(Attribute $attribute) * @method static \Lunar\Admin\Support\Forms\AttributeData registerFieldType(string $coreFieldType, string $panelFieldType) * @method static Collection getFieldTypes() + * @method static array mutateConfigurationForForm(string|null $type = null, array $configuration = []) * @method static array getConfigurationFields(string|null $type = null) * @method static void synthesizeLivewireProperties() * diff --git a/packages/admin/src/Support/FieldTypes/BaseFieldType.php b/packages/admin/src/Support/FieldTypes/BaseFieldType.php index 856019030e..33c160e6b7 100644 --- a/packages/admin/src/Support/FieldTypes/BaseFieldType.php +++ b/packages/admin/src/Support/FieldTypes/BaseFieldType.php @@ -11,6 +11,15 @@ abstract class BaseFieldType { protected static string $synthesizer = TextSynth::class; + /** + * @param array $configuration + * @return array + */ + public static function mutateConfigurationForForm(array $configuration): array + { + return $configuration; + } + public static function getConfigurationFields(): array { return []; diff --git a/packages/admin/src/Support/FieldTypes/Dropdown.php b/packages/admin/src/Support/FieldTypes/Dropdown.php index e16a98bdbe..761394ab50 100644 --- a/packages/admin/src/Support/FieldTypes/Dropdown.php +++ b/packages/admin/src/Support/FieldTypes/Dropdown.php @@ -12,6 +12,38 @@ class Dropdown extends BaseFieldType { protected static string $synthesizer = DropdownSynth::class; + /** + * @param array $configuration + * @return array + */ + public static function mutateConfigurationForForm(array $configuration): array + { + $lookups = $configuration['lookups'] ?? []; + + if (! is_array($lookups)) { + return $configuration; + } + + $configuration['lookups'] = collect($lookups) + ->mapWithKeys(function (mixed $lookup, mixed $key): array { + if (! is_array($lookup)) { + return [$key => $lookup]; + } + + $label = $lookup['label'] ?? $lookup['key'] ?? null; + $value = $lookup['value'] ?? $label; + + if (blank($label)) { + return []; + } + + return [$label => $value]; + }) + ->all(); + + return $configuration; + } + public static function getFilamentComponent(Attribute $attribute): Component { return Select::make($attribute->handle) diff --git a/packages/admin/src/Support/Forms/AttributeData.php b/packages/admin/src/Support/Forms/AttributeData.php index d580868a90..e6b9c38b63 100644 --- a/packages/admin/src/Support/Forms/AttributeData.php +++ b/packages/admin/src/Support/Forms/AttributeData.php @@ -97,6 +97,17 @@ public function getFieldTypes(): Collection return collect($this->fieldTypes)->keys(); } + /** + * @param array $configuration + * @return array + */ + public function mutateConfigurationForForm(?string $type = null, array $configuration = []): array + { + $fieldType = $this->fieldTypes[$type] ?? null; + + return $fieldType ? $fieldType::mutateConfigurationForForm($configuration) : $configuration; + } + public function getConfigurationFields(?string $type = null): array { $fieldType = $this->fieldTypes[$type] ?? null; diff --git a/packages/admin/src/Support/Pages/BaseEditRecord.php b/packages/admin/src/Support/Pages/BaseEditRecord.php index 22d4e6cc8d..a9550cbbfa 100644 --- a/packages/admin/src/Support/Pages/BaseEditRecord.php +++ b/packages/admin/src/Support/Pages/BaseEditRecord.php @@ -3,7 +3,9 @@ namespace Lunar\Admin\Support\Pages; use Filament\Resources\Pages\EditRecord; +use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\Model; +use Lunar\Base\FieldType; use Lunar\Admin\Support\Concerns\CallsHooks; use Lunar\Admin\Support\Pages\Concerns\ExtendsFooterWidgets; use Lunar\Admin\Support\Pages\Concerns\ExtendsFormActions; @@ -24,7 +26,7 @@ abstract class BaseEditRecord extends EditRecord protected function mutateFormDataBeforeFill(array $data): array { - return $this->callLunarHook('beforeFill', $data); + return $this->callLunarHook('beforeFill', $this->normalizeFieldTypeState($data)); } protected function mutateFormDataBeforeSave(array $data): array @@ -47,4 +49,26 @@ public function afterSave() $this->getRecord() ); } + + protected function normalizeFieldTypeState(mixed $state): mixed + { + if ($state instanceof FieldType) { + return $this->normalizeFieldTypeState($state->getValue()); + } + + if ($state instanceof Collection) { + return $state + ->map(fn (mixed $value): mixed => $this->normalizeFieldTypeState($value)) + ->all(); + } + + if (is_array($state)) { + return array_map( + fn (mixed $value): mixed => $this->normalizeFieldTypeState($value), + $state, + ); + } + + return $state; + } } 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 @@ +value ?? '[]'); + return json_decode($this->value ?? '[]', associative: true); } /** diff --git a/packages/core/src/LunarServiceProvider.php b/packages/core/src/LunarServiceProvider.php index b0767a2f5a..a2ed7b5f5a 100644 --- a/packages/core/src/LunarServiceProvider.php +++ b/packages/core/src/LunarServiceProvider.php @@ -26,12 +26,16 @@ use Lunar\Base\FieldTypeManifestInterface; use Lunar\Base\ModelManifest; use Lunar\Base\ModelManifestInterface; +use Lunar\Base\OrderLineCapabilities; +use Lunar\Base\OrderLineCapabilitiesInterface; use Lunar\Base\OrderModifiers; use Lunar\Base\OrderReferenceGenerator; use Lunar\Base\OrderReferenceGeneratorInterface; use Lunar\Base\PaymentManagerInterface; use Lunar\Base\PricingManagerInterface; use Lunar\Base\ProvidesTelemetryInsights; +use Lunar\Base\RefundAuthorization; +use Lunar\Base\RefundAuthorizationInterface; use Lunar\Base\ShippingManifest; use Lunar\Base\ShippingManifestInterface; use Lunar\Base\ShippingModifiers; @@ -173,6 +177,10 @@ public function register(): void return $app->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/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php b/tests/admin/Feature/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php index 3139ff0394..7e2861efc2 100644 --- a/tests/admin/Feature/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php +++ b/tests/admin/Feature/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php @@ -1,6 +1,7 @@ create([ + 'default' => true, + 'code' => 'en', + ]); + + $this->asStaff(); + + $attributeGroup = AttributeGroup::factory()->create(); + + $attribute = Attribute::factory()->create([ + 'attribute_group_id' => $attributeGroup->id, + 'type' => Dropdown::class, + 'configuration' => [ + 'lookups' => [ + ['label' => 'aaaa', 'value' => 'bbbb'], + ], + ], + ]); + + Livewire::test(AttributesRelationManager::class, [ + 'ownerRecord' => $attributeGroup, + 'pageClass' => EditAttributeGroup::class, + ]) + ->mountTableAction(EditAction::class, $attribute) + ->assertTableActionDataSet([ + 'configuration' => [ + 'lookups' => [ + 'aaaa' => 'bbbb', + ], + ], + ]); +}); 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/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php b/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php index f692b68505..4659d51fb3 100644 --- a/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php +++ b/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php @@ -3,6 +3,8 @@ use Illuminate\Support\Facades\DB; use Livewire\Livewire; use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct; +use Lunar\FieldTypes\Dropdown; +use Lunar\FieldTypes\ListField as ListFieldType; use Lunar\FieldTypes\Number; use Lunar\FieldTypes\Text; use Lunar\FieldTypes\Toggle; @@ -191,3 +193,146 @@ expect($record->refresh()->attr('name'))->toBe('New Product Name'); }); + +it('hydrates numeric, list, and option attributes as form values', function () { + Language::factory()->create([ + 'default' => true, + ]); + + TaxClass::factory()->create([ + 'default' => true, + ]); + + $record = Product::factory()->create(); + ProductVariant::factory()->create([ + 'product_id' => $record->id, + ]); + + $group = AttributeGroup::factory()->create([ + 'attributable_type' => 'product', + 'name' => [ + 'en' => 'Sample Details', + ], + 'handle' => 'sample_details', + 'position' => 1, + ]); + + $measurementAttribute = Attribute::factory()->create([ + 'attribute_type' => 'product', + 'attribute_group_id' => $group->id, + 'position' => 1, + 'name' => [ + 'en' => 'Sample Measurement', + ], + 'description' => [ + 'en' => 'A sample measurement field.', + ], + 'handle' => 'sample_measurement', + 'section' => 'main', + 'type' => Number::class, + 'configuration' => [ + 'min' => 1, + ], + 'required' => false, + 'system' => false, + 'searchable' => false, + ]); + + $classificationAttribute = Attribute::factory()->create([ + 'attribute_type' => 'product', + 'attribute_group_id' => $group->id, + 'position' => 2, + 'name' => [ + 'en' => 'Sample Classification', + ], + 'description' => [ + 'en' => 'A sample option field.', + ], + 'handle' => 'sample_classification', + 'section' => 'main', + 'type' => Dropdown::class, + 'configuration' => [ + 'lookups' => [ + [ + 'label' => 'Standard', + 'value' => 'standard', + ], + [ + 'label' => 'Enhanced', + 'value' => 'enhanced', + ], + ], + ], + 'required' => false, + 'system' => false, + 'searchable' => false, + ]); + + $formFactorAttribute = Attribute::factory()->create([ + 'attribute_type' => 'product', + 'attribute_group_id' => $group->id, + 'position' => 3, + 'name' => [ + 'en' => 'Sample Form Factors', + ], + 'description' => [ + 'en' => 'Sample keyed form-factor data.', + ], + 'handle' => 'sample_form_factors', + 'section' => 'main', + 'type' => ListFieldType::class, + 'required' => false, + 'system' => false, + 'searchable' => false, + ]); + + DB::table('lunar_attributables')->insert([ + [ + 'attribute_id' => $measurementAttribute->id, + 'attributable_type' => 'product_type', + 'attributable_id' => $record->productType->id, + ], + [ + 'attribute_id' => $classificationAttribute->id, + 'attributable_type' => 'product_type', + 'attributable_id' => $record->productType->id, + ], + [ + 'attribute_id' => $formFactorAttribute->id, + 'attributable_type' => 'product_type', + 'attributable_id' => $record->productType->id, + ], + ]); + + $record->update([ + 'attribute_data' => collect([ + ...($record->attribute_data?->all() ?? []), + 'sample_measurement' => new Number(333), + 'sample_classification' => new Dropdown('standard'), + 'sample_form_factors' => new ListFieldType([ + 'card' => 'contact_card', + 'tag' => 'luggage_tag', + ]), + ]), + ]); + + $this->asStaff(admin: true); + + Livewire::test(EditProduct::class, [ + 'record' => $record->getRouteKey(), + 'pageClass' => 'productEdit', + ])->assertFormSet([ + 'attribute_data.sample_measurement' => 333, + 'attribute_data.sample_classification' => 'standard', + 'attribute_data.sample_form_factors' => [ + [ + 'key' => 'card', + 'value' => 'contact_card', + ], + [ + 'key' => 'tag', + 'value' => 'luggage_tag', + ], + ], + ]); +}); diff --git a/tests/admin/Unit/Support/Forms/AttributeDataTest.php b/tests/admin/Unit/Support/Forms/AttributeDataTest.php index 0a9d4f79ab..01a74bc966 100644 --- a/tests/admin/Unit/Support/Forms/AttributeDataTest.php +++ b/tests/admin/Unit/Support/Forms/AttributeDataTest.php @@ -52,6 +52,7 @@ $inputComponent = AttributeData::getFilamentComponent($attribute); expect($inputComponent)->toBeInstanceOf(RichEditor::class); }); + }); class TestFieldType extends Text {} 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/FieldTypes/ListFieldTest.php b/tests/core/Unit/FieldTypes/ListFieldTest.php index 80e4d6b98b..989652da34 100644 --- a/tests/core/Unit/FieldTypes/ListFieldTest.php +++ b/tests/core/Unit/FieldTypes/ListFieldTest.php @@ -23,6 +23,18 @@ expect($field->getValue())->toEqual(['Foo']); }); +test('can return keyed values as an array', function () { + $field = new ListField([ + 'card' => 'contact_card', + 'tag' => 'luggage_tag', + ]); + + expect($field->getValue())->toEqual([ + 'card' => 'contact_card', + 'tag' => 'luggage_tag', + ]); +}); + test('check does not allow non arrays', function () { $this->expectException(FieldTypeException::class); 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 () { From ddf897282895d15e6fedfe42df86315e961d1220 Mon Sep 17 00:00:00 2001 From: Mitch Schwenk Date: Mon, 13 Apr 2026 15:45:46 -0700 Subject: [PATCH 2/2] Remove unrelated admin attribute hydration changes from checkout hooks branch --- composer.json | 4 +- .../AttributesRelationManager.php | 10 +- .../src/Support/Facades/AttributeData.php | 1 - .../src/Support/FieldTypes/BaseFieldType.php | 9 -- .../admin/src/Support/FieldTypes/Dropdown.php | 32 ---- .../admin/src/Support/Forms/AttributeData.php | 11 -- .../src/Support/Pages/BaseEditRecord.php | 26 +--- packages/core/src/FieldTypes/ListField.php | 2 +- .../AttributesRelationManagerTest.php | 35 ----- .../ProductResource/Pages/EditProductTest.php | 145 ------------------ .../Unit/Support/Forms/AttributeDataTest.php | 1 - tests/core/Unit/FieldTypes/ListFieldTest.php | 12 -- 12 files changed, 5 insertions(+), 283 deletions(-) diff --git a/composer.json b/composer.json index 9b83a587d9..034fd1916d 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "typesense/typesense-php": "^4.9" }, "require-dev": { - "filament/upgrade": "^4.0", +"filament/upgrade": "^4.0", "larastan/larastan": "^3.0", "laravel/pint": "1.29.0", "mockery/mockery": "^1.6.9", @@ -88,7 +88,7 @@ } }, "extra": { - "lunar": { +"lunar": { "name": [ "Table Rate Shipping", "Opayo Payments", diff --git a/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php b/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php index d43eb21118..108cd2871e 100644 --- a/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php +++ b/packages/admin/src/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManager.php @@ -162,15 +162,7 @@ public function getDefaultTable(Table $table): Table }), ]) ->recordActions([ - EditAction::make() - ->mutateRecordDataUsing(function (array $data): array { - $data['configuration'] = AttributeData::mutateConfigurationForForm( - $data['type'] ?? null, - $data['configuration'] ?? [], - ); - - return $data; - }), + EditAction::make(), DeleteAction::make(), ]) ->toolbarActions([ diff --git a/packages/admin/src/Support/Facades/AttributeData.php b/packages/admin/src/Support/Facades/AttributeData.php index 6c0ce93e8d..26c0b75a15 100644 --- a/packages/admin/src/Support/Facades/AttributeData.php +++ b/packages/admin/src/Support/Facades/AttributeData.php @@ -11,7 +11,6 @@ * @method static Component getFilamentComponent(Attribute $attribute) * @method static \Lunar\Admin\Support\Forms\AttributeData registerFieldType(string $coreFieldType, string $panelFieldType) * @method static Collection getFieldTypes() - * @method static array mutateConfigurationForForm(string|null $type = null, array $configuration = []) * @method static array getConfigurationFields(string|null $type = null) * @method static void synthesizeLivewireProperties() * diff --git a/packages/admin/src/Support/FieldTypes/BaseFieldType.php b/packages/admin/src/Support/FieldTypes/BaseFieldType.php index 33c160e6b7..856019030e 100644 --- a/packages/admin/src/Support/FieldTypes/BaseFieldType.php +++ b/packages/admin/src/Support/FieldTypes/BaseFieldType.php @@ -11,15 +11,6 @@ abstract class BaseFieldType { protected static string $synthesizer = TextSynth::class; - /** - * @param array $configuration - * @return array - */ - public static function mutateConfigurationForForm(array $configuration): array - { - return $configuration; - } - public static function getConfigurationFields(): array { return []; diff --git a/packages/admin/src/Support/FieldTypes/Dropdown.php b/packages/admin/src/Support/FieldTypes/Dropdown.php index 761394ab50..e16a98bdbe 100644 --- a/packages/admin/src/Support/FieldTypes/Dropdown.php +++ b/packages/admin/src/Support/FieldTypes/Dropdown.php @@ -12,38 +12,6 @@ class Dropdown extends BaseFieldType { protected static string $synthesizer = DropdownSynth::class; - /** - * @param array $configuration - * @return array - */ - public static function mutateConfigurationForForm(array $configuration): array - { - $lookups = $configuration['lookups'] ?? []; - - if (! is_array($lookups)) { - return $configuration; - } - - $configuration['lookups'] = collect($lookups) - ->mapWithKeys(function (mixed $lookup, mixed $key): array { - if (! is_array($lookup)) { - return [$key => $lookup]; - } - - $label = $lookup['label'] ?? $lookup['key'] ?? null; - $value = $lookup['value'] ?? $label; - - if (blank($label)) { - return []; - } - - return [$label => $value]; - }) - ->all(); - - return $configuration; - } - public static function getFilamentComponent(Attribute $attribute): Component { return Select::make($attribute->handle) diff --git a/packages/admin/src/Support/Forms/AttributeData.php b/packages/admin/src/Support/Forms/AttributeData.php index ce2ad59333..49ea986fa5 100644 --- a/packages/admin/src/Support/Forms/AttributeData.php +++ b/packages/admin/src/Support/Forms/AttributeData.php @@ -104,17 +104,6 @@ public function getFieldTypes(): Collection return collect($this->fieldTypes)->keys(); } - /** - * @param array $configuration - * @return array - */ - public function mutateConfigurationForForm(?string $type = null, array $configuration = []): array - { - $fieldType = $this->fieldTypes[$type] ?? null; - - return $fieldType ? $fieldType::mutateConfigurationForForm($configuration) : $configuration; - } - public function getConfigurationFields(?string $type = null): array { $fieldType = $this->fieldTypes[$type] ?? null; diff --git a/packages/admin/src/Support/Pages/BaseEditRecord.php b/packages/admin/src/Support/Pages/BaseEditRecord.php index a9550cbbfa..22d4e6cc8d 100644 --- a/packages/admin/src/Support/Pages/BaseEditRecord.php +++ b/packages/admin/src/Support/Pages/BaseEditRecord.php @@ -3,9 +3,7 @@ namespace Lunar\Admin\Support\Pages; use Filament\Resources\Pages\EditRecord; -use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\Model; -use Lunar\Base\FieldType; use Lunar\Admin\Support\Concerns\CallsHooks; use Lunar\Admin\Support\Pages\Concerns\ExtendsFooterWidgets; use Lunar\Admin\Support\Pages\Concerns\ExtendsFormActions; @@ -26,7 +24,7 @@ abstract class BaseEditRecord extends EditRecord protected function mutateFormDataBeforeFill(array $data): array { - return $this->callLunarHook('beforeFill', $this->normalizeFieldTypeState($data)); + return $this->callLunarHook('beforeFill', $data); } protected function mutateFormDataBeforeSave(array $data): array @@ -49,26 +47,4 @@ public function afterSave() $this->getRecord() ); } - - protected function normalizeFieldTypeState(mixed $state): mixed - { - if ($state instanceof FieldType) { - return $this->normalizeFieldTypeState($state->getValue()); - } - - if ($state instanceof Collection) { - return $state - ->map(fn (mixed $value): mixed => $this->normalizeFieldTypeState($value)) - ->all(); - } - - if (is_array($state)) { - return array_map( - fn (mixed $value): mixed => $this->normalizeFieldTypeState($value), - $state, - ); - } - - return $state; - } } diff --git a/packages/core/src/FieldTypes/ListField.php b/packages/core/src/FieldTypes/ListField.php index cc96f1e550..f7a282cdb3 100644 --- a/packages/core/src/FieldTypes/ListField.php +++ b/packages/core/src/FieldTypes/ListField.php @@ -42,7 +42,7 @@ public function jsonSerialize(): mixed */ public function getValue() { - return json_decode($this->value ?? '[]', associative: true); + return json_decode($this->value ?? '[]'); } /** diff --git a/tests/admin/Feature/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php b/tests/admin/Feature/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php index 57570feda8..eae310cc8a 100644 --- a/tests/admin/Feature/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php +++ b/tests/admin/Feature/Filament/Resources/AttributeGroupResource/RelationManagers/AttributesRelationManagerTest.php @@ -1,7 +1,6 @@ create([ - 'default' => true, - 'code' => 'en', - ]); - - $this->asStaff(); - - $attributeGroup = AttributeGroup::factory()->create(); - - $attribute = Attribute::factory()->create([ - 'attribute_group_id' => $attributeGroup->id, - 'type' => Dropdown::class, - 'configuration' => [ - 'lookups' => [ - ['label' => 'aaaa', 'value' => 'bbbb'], - ], - ], - ]); - - Livewire::test(AttributesRelationManager::class, [ - 'ownerRecord' => $attributeGroup, - 'pageClass' => EditAttributeGroup::class, - ]) - ->mountTableAction(EditAction::class, $attribute) - ->assertTableActionDataSet([ - 'configuration' => [ - 'lookups' => [ - 'aaaa' => 'bbbb', - ], - ], - ]); -}); diff --git a/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php b/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php index 8e66c20c5c..d1de879757 100644 --- a/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php +++ b/tests/admin/Feature/Filament/Resources/ProductResource/Pages/EditProductTest.php @@ -3,8 +3,6 @@ use Illuminate\Support\Facades\DB; use Livewire\Livewire; use Lunar\Admin\Filament\Resources\ProductResource\Pages\EditProduct; -use Lunar\FieldTypes\Dropdown; -use Lunar\FieldTypes\ListField as ListFieldType; use Lunar\FieldTypes\Number; use Lunar\FieldTypes\Text; use Lunar\FieldTypes\Toggle; @@ -195,149 +193,6 @@ expect($record->refresh()->attr('name'))->toBe('New Product Name'); }); -it('hydrates numeric, list, and option attributes as form values', function () { - Language::factory()->create([ - 'default' => true, - ]); - - TaxClass::factory()->create([ - 'default' => true, - ]); - - $record = Product::factory()->create(); - ProductVariant::factory()->create([ - 'product_id' => $record->id, - ]); - - $group = AttributeGroup::factory()->create([ - 'attributable_type' => 'product', - 'name' => [ - 'en' => 'Sample Details', - ], - 'handle' => 'sample_details', - 'position' => 1, - ]); - - $measurementAttribute = Attribute::factory()->create([ - 'attribute_type' => 'product', - 'attribute_group_id' => $group->id, - 'position' => 1, - 'name' => [ - 'en' => 'Sample Measurement', - ], - 'description' => [ - 'en' => 'A sample measurement field.', - ], - 'handle' => 'sample_measurement', - 'section' => 'main', - 'type' => Number::class, - 'configuration' => [ - 'min' => 1, - ], - 'required' => false, - 'system' => false, - 'searchable' => false, - ]); - - $classificationAttribute = Attribute::factory()->create([ - 'attribute_type' => 'product', - 'attribute_group_id' => $group->id, - 'position' => 2, - 'name' => [ - 'en' => 'Sample Classification', - ], - 'description' => [ - 'en' => 'A sample option field.', - ], - 'handle' => 'sample_classification', - 'section' => 'main', - 'type' => Dropdown::class, - 'configuration' => [ - 'lookups' => [ - [ - 'label' => 'Standard', - 'value' => 'standard', - ], - [ - 'label' => 'Enhanced', - 'value' => 'enhanced', - ], - ], - ], - 'required' => false, - 'system' => false, - 'searchable' => false, - ]); - - $formFactorAttribute = Attribute::factory()->create([ - 'attribute_type' => 'product', - 'attribute_group_id' => $group->id, - 'position' => 3, - 'name' => [ - 'en' => 'Sample Form Factors', - ], - 'description' => [ - 'en' => 'Sample keyed form-factor data.', - ], - 'handle' => 'sample_form_factors', - 'section' => 'main', - 'type' => ListFieldType::class, - 'required' => false, - 'system' => false, - 'searchable' => false, - ]); - - DB::table('lunar_attributables')->insert([ - [ - 'attribute_id' => $measurementAttribute->id, - 'attributable_type' => 'product_type', - 'attributable_id' => $record->productType->id, - ], - [ - 'attribute_id' => $classificationAttribute->id, - 'attributable_type' => 'product_type', - 'attributable_id' => $record->productType->id, - ], - [ - 'attribute_id' => $formFactorAttribute->id, - 'attributable_type' => 'product_type', - 'attributable_id' => $record->productType->id, - ], - ]); - - $record->update([ - 'attribute_data' => collect([ - ...($record->attribute_data?->all() ?? []), - 'sample_measurement' => new Number(333), - 'sample_classification' => new Dropdown('standard'), - 'sample_form_factors' => new ListFieldType([ - 'card' => 'contact_card', - 'tag' => 'luggage_tag', - ]), - ]), - ]); - - $this->asStaff(admin: true); - - Livewire::test(EditProduct::class, [ - 'record' => $record->getRouteKey(), - 'pageClass' => 'productEdit', - ])->assertFormSet([ - 'attribute_data.sample_measurement' => 333, - 'attribute_data.sample_classification' => 'standard', - 'attribute_data.sample_form_factors' => [ - [ - 'key' => 'card', - 'value' => 'contact_card', - ], - [ - 'key' => 'tag', - 'value' => 'luggage_tag', - ], - ], - ]); -}); - it('hydrates translated rich text fields with all locale keys', function () { CustomerGroup::factory()->create([ 'default' => true, diff --git a/tests/admin/Unit/Support/Forms/AttributeDataTest.php b/tests/admin/Unit/Support/Forms/AttributeDataTest.php index 01a74bc966..0a9d4f79ab 100644 --- a/tests/admin/Unit/Support/Forms/AttributeDataTest.php +++ b/tests/admin/Unit/Support/Forms/AttributeDataTest.php @@ -52,7 +52,6 @@ $inputComponent = AttributeData::getFilamentComponent($attribute); expect($inputComponent)->toBeInstanceOf(RichEditor::class); }); - }); class TestFieldType extends Text {} diff --git a/tests/core/Unit/FieldTypes/ListFieldTest.php b/tests/core/Unit/FieldTypes/ListFieldTest.php index 989652da34..80e4d6b98b 100644 --- a/tests/core/Unit/FieldTypes/ListFieldTest.php +++ b/tests/core/Unit/FieldTypes/ListFieldTest.php @@ -23,18 +23,6 @@ expect($field->getValue())->toEqual(['Foo']); }); -test('can return keyed values as an array', function () { - $field = new ListField([ - 'card' => 'contact_card', - 'tag' => 'luggage_tag', - ]); - - expect($field->getValue())->toEqual([ - 'card' => 'contact_card', - 'tag' => 'luggage_tag', - ]); -}); - test('check does not allow non arrays', function () { $this->expectException(FieldTypeException::class);