1, 'country_id' => 1]); $shopOwner = User::forceCreate([ 'email' => 'wh-owner-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', ]); $userShop = UserShop::first() ?? UserShop::create([ 'user_id' => $shopOwner->id, 'name' => 'WH Shop', 'slug' => 'wh-shop-'.uniqid(), 'domain' => 'wh-test.local', ]); $shoppingUser = ShoppingUser::create([ 'auth_user_id' => $shopOwner->id, 'billing_firstname' => 'Web', 'billing_lastname' => 'Hook', 'billing_email' => 'webhook-'.uniqid().'@example.com', 'billing_country_id' => $country->id, 'shipping_country_id' => $country->id, ]); $order = ShoppingOrder::create(array_merge([ 'shopping_user_id' => $shoppingUser->id, 'auth_user_id' => $shopOwner->id, 'country_id' => $country->id, 'user_shop_id' => $userShop->id, 'total' => 89.00, 'paid' => false, 'txaction' => null, 'mode' => 'test', ], $orderOverrides)); // txaction is not fillable on ShoppingPayment; apply it explicitly after create. $paymentTxaction = $paymentOverrides['txaction'] ?? null; unset($paymentOverrides['txaction']); $payment = ShoppingPayment::create(array_merge([ 'shopping_order_id' => $order->id, 'reference' => 'wh-ref-'.uniqid(), 'amount' => 8900, 'clearingtype' => 'cc', 'currency' => 'EUR', 'mode' => 'test', ], $paymentOverrides)); if ($paymentTxaction !== null) { $payment->txaction = $paymentTxaction; $payment->save(); } return [$order, $payment]; } function validCallback(ShoppingOrder $order, ShoppingPayment $payment, string $txaction): array { return [ 'key' => config('payone.defaults.key'), 'txaction' => $txaction, 'clearingtype' => 'cc', 'userid' => '158723953', 'txid' => '321623031', 'price' => '89.00', 'param' => $order->id, 'reference' => $payment->reference, 'mode' => 'test', ]; } // --- Validation / rejection paths --- it('rejects an incomplete callback with code 2001', function (): void { $result = (new PayoneWebhookHandler)->handle(['txaction' => 'paid']); expect($result)->toBeInstanceOf(PayoneWebhookResult::class) ->and($result->response)->toBe('TSOK') ->and($result->processed)->toBeFalse() ->and($result->rejectionCode)->toBe('2001'); }); it('rejects a callback missing txaction with code 2001', function (): void { [$order, $payment] = webhookBootstrap(); $data = validCallback($order, $payment, 'paid'); unset($data['txaction']); $result = (new PayoneWebhookHandler)->handle($data); expect($result->processed)->toBeFalse() ->and($result->rejectionCode)->toBe('2001'); }); it('rejects a callback with a wrong key (2002)', function (): void { [$order, $payment] = webhookBootstrap(); $data = validCallback($order, $payment, 'paid'); $data['key'] = 'totally-wrong-key'; $result = (new PayoneWebhookHandler)->handle($data); expect($result->processed)->toBeFalse() ->and($result->rejectionCode)->toBe('2002'); }); it('rejects when the shopping order is not found (2003)', function (): void { [$order, $payment] = webhookBootstrap(); $data = validCallback($order, $payment, 'paid'); $data['param'] = 999999; $result = (new PayoneWebhookHandler)->handle($data); expect($result->rejectionCode)->toBe('2003'); }); it('rejects when the shopping payment is not found (2004)', function (): void { [$order, $payment] = webhookBootstrap(); $data = validCallback($order, $payment, 'paid'); $data['reference'] = 'does-not-exist'; $result = (new PayoneWebhookHandler)->handle($data); expect($result->rejectionCode)->toBe('2004'); }); it('rejects when payment does not belong to the order (2005)', function (): void { [$order, $payment] = webhookBootstrap(); [$otherOrder] = webhookBootstrap(); $data = validCallback($order, $payment, 'paid'); // Point param at a different existing order than the payment's order. $data['param'] = $otherOrder->id; $result = (new PayoneWebhookHandler)->handle($data); expect($result->rejectionCode)->toBe('2005'); }); it('rejects on a price mismatch (2006)', function (): void { [$order, $payment] = webhookBootstrap(paymentOverrides: ['amount' => 5000]); $data = validCallback($order, $payment, 'paid'); // price 89.00 -> 8900 != 5000 $result = (new PayoneWebhookHandler)->handle($data); expect($result->rejectionCode)->toBe('2006'); }); // --- Duplicate handling --- it('ignores a duplicate paid callback when already paid', function (): void { [$order, $payment] = webhookBootstrap( orderOverrides: ['txaction' => 'paid', 'paid' => true], paymentOverrides: ['txaction' => 'paid'], ); $data = validCallback($order, $payment, 'paid'); $result = (new PayoneWebhookHandler)->handle($data); expect($result->processed)->toBeFalse() ->and($result->duplicate)->toBeTrue() ->and($result->txaction)->toBe('paid'); // No new transaction recorded for the ignored duplicate. expect(PaymentTransaction::count())->toBe(0); }); it('ignores a duplicate appointed callback with same txaction', function (): void { [$order, $payment] = webhookBootstrap( orderOverrides: ['txaction' => 'appointed'], paymentOverrides: ['txaction' => 'appointed'], ); $data = validCallback($order, $payment, 'appointed'); $result = (new PayoneWebhookHandler)->handle($data); expect($result->duplicate)->toBeTrue() ->and($result->txaction)->toBe('appointed'); expect(PaymentTransaction::count())->toBe(0); }); // --- Processing paths --- it('processes an appointed callback: records transaction and updates txaction', function (): void { [$order, $payment] = webhookBootstrap(); $data = validCallback($order, $payment, 'appointed'); $result = (new PayoneWebhookHandler)->handle($data); expect($result->processed)->toBeTrue() ->and($result->txaction)->toBe('appointed') ->and($result->paid)->toBeFalse(); $order->refresh(); $payment->refresh(); expect($order->txaction)->toBe('appointed') ->and($payment->txaction)->toBe('appointed') ->and(PaymentTransaction::count())->toBe(1); }); it('does not downgrade txaction when a lower-priority callback arrives', function (): void { // Order already 'paid' (priority 10), incoming 'appointed' (priority 1) must not override. [$order, $payment] = webhookBootstrap( orderOverrides: ['txaction' => 'paid', 'paid' => true], paymentOverrides: ['txaction' => 'pending'], ); $data = validCallback($order, $payment, 'appointed'); $result = (new PayoneWebhookHandler)->handle($data); expect($result->processed)->toBeTrue(); $order->refresh(); expect($order->txaction)->toBe('paid'); // unchanged // Transaction still recorded even though txaction was not updated. expect(PaymentTransaction::count())->toBe(1); }); it('processes a paid callback: marks order paid and records transaction', function (): void { [$order, $payment] = webhookBootstrap(); $data = validCallback($order, $payment, 'paid'); $result = (new PayoneWebhookHandler)->handle($data); expect($result->processed)->toBeTrue() ->and($result->paid)->toBeTrue() ->and($result->mailSent)->toBeTrue(); $order->refresh(); expect((bool) $order->paid)->toBeTrue() ->and($order->txaction)->toBe('paid') ->and(PaymentTransaction::count())->toBe(1); });