diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index c7ca8ac3..d6ba19d3 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,487 +1,487 @@ guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', - 'methodId' => $request->methodId + 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold // we'll top-up the wallet with the configured auto-payment amount if ($wallet->balance < intval($request->balance * 100)) { $mandate['amount'] = intval($request->amount * 100); } $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => \trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, // Re-enable the mandate to give it a chance to charge again // after it has been disabled (e.g. because the mandate amount was too small) 'mandate_disabled' => null, ]); // Trigger auto-payment if the balance is below the threshold if ($wallet->balance < intval($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = \trans('app.mandate-update-success'); return response()->json($result); } /** * Validate an auto-payment mandate request. * * @param \Illuminate\Http\Request $request The API request. * @param \App\Wallet $wallet The wallet * * @return array|null List of errors on error or Null on success */ protected static function mandateValidate(Request $request, Wallet $wallet) { $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return $v->errors()->toArray(); } $amount = (int) ($request->amount * 100); // Validate the minimum value // It has to be at least minimum payment amount and must cover current debt if ( $wallet->balance < 0 && $wallet->balance <= PaymentProvider::MIN_AMOUNT * -1 && $wallet->balance + $amount < 0 ) { return ['amount' => \trans('validation.minamountdebt')]; } if ($amount < PaymentProvider::MIN_AMOUNT) { $min = $wallet->money(PaymentProvider::MIN_AMOUNT); return ['amount' => \trans('validation.minamount', ['amount' => $min])]; } return null; } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < PaymentProvider::MIN_AMOUNT) { $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $currency = $request->currency; $request = [ 'type' => PaymentProvider::TYPE_ONEOFF, 'currency' => $currency, 'amount' => $amount, - 'methodId' => $request->methodId, + 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; $provider = PaymentProvider::factory($wallet, $currency); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Delete a pending payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ // TODO currently unused // public function cancel(Request $request) // { // $user = $this->guard()->user(); // // TODO: Wallet selection // $wallet = $user->wallets()->first(); // $paymentId = $request->payment; // $user_owns_payment = Payment::where('id', $paymentId) // ->where('wallet_id', $wallet->id) // ->exists(); // if (!$user_owns_payment) { // return $this->errorResponse(404); // } // $provider = PaymentProvider::factory($wallet); // if ($provider->cancel($wallet, $paymentId)) { // $result = ['status' => 'success']; // return response()->json($result); // } // return $this->errorResponse(404); // } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); \Log::debug("Requested top-up for wallet {$wallet->id}"); if (!empty($settings['mandate_disabled'])) { \Log::debug("Top-up for wallet {$wallet->id}: mandate disabled"); return false; } $min_balance = (int) (floatval($settings['mandate_balance']) * 100); $amount = (int) (floatval($settings['mandate_amount']) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } $provider = PaymentProvider::factory($wallet); $mandate = (array) $provider->getMandate($wallet); if (empty($mandate['isValid'])) { \Log::debug("Top-up for wallet {$wallet->id}: mandate invalid"); return false; } // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { // Disable (not remove) the mandate $wallet->setSetting('mandate_disabled', 1); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $request = [ 'type' => PaymentProvider::TYPE_RECURRING, 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment', ]; $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { $mandate[$key] = $value; } } return $mandate; } /** * List supported payment methods. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentMethods(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $methods = PaymentProvider::paymentMethods($wallet, $request->type); \Log::debug("Provider methods" . var_export(json_encode($methods), true)); return response()->json($methods); } /** * Check for pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function hasPayments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $exists = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ PaymentProvider::STATUS_OPEN, PaymentProvider::STATUS_PENDING, PaymentProvider::STATUS_AUTHORIZED ]) ->exists(); return response()->json([ 'status' => 'success', 'hasPending' => $exists ]); } /** * List pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function payments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ PaymentProvider::STATUS_OPEN, PaymentProvider::STATUS_PENDING, PaymentProvider::STATUS_AUTHORIZED ]) ->orderBy('created_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } $result = $result->map(function ($item) use ($wallet) { $provider = PaymentProvider::factory($item->provider); $payment = $provider->getPayment($item->id); $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->description, 'amount' => $item->amount, 'currency' => $wallet->currency, // note: $item->currency/$item->currency_amount might be different 'status' => $item->status, 'isCancelable' => $payment['isCancelable'], 'checkoutUrl' => $payment['checkoutUrl'] ]; return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } } diff --git a/src/tests/Browser/Pages/PaymentMollie.php b/src/tests/Browser/Pages/PaymentMollie.php index 25d090ef..537c2fcb 100644 --- a/src/tests/Browser/Pages/PaymentMollie.php +++ b/src/tests/Browser/Pages/PaymentMollie.php @@ -1,77 +1,76 @@ waitFor('form#body table, form#body iframe'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@title' => '#container .header__info', '@amount' => '#container .header__amount', ]; } /** * Submit payment form. * * @param \Laravel\Dusk\Browser $browser The browser object * @param string $status Test payment status (paid, open, failed, expired) * * @return void */ public function submitPayment($browser, $status = 'paid') { // https://docs.mollie.com/overview/testing // https://docs.mollie.com/components/testing - if ($browser->element('form#body iframe')) { $browser->withinFrame('#card-number iframe', function ($browser) { $browser->type('#cardNumber', '2223 0000 1047 9399'); // Mastercard }) ->withinFrame('#card-holder-name iframe', function ($browser) { $browser->type('#cardHolder', 'Test'); }) ->withinFrame('#expiry-date iframe', function ($browser) { $browser->type('#expiryDate', '12/' . (date('y') + 1)); }) ->withinFrame('#cvv iframe', function ($browser) { $browser->type('#verificationCode', '123'); }) ->click('#submit-button'); } $browser->waitFor('input[value="' . $status . '"]') ->click('input[value="' . $status . '"]') ->click('button.form__button'); } } diff --git a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php index e9ec7fc6..eac7ee20 100644 --- a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php @@ -1,936 +1,936 @@ 'mollie']); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('euro@' . \config('app.domain')); parent::tearDown(); } /** * Test creating/updating/deleting an outo-payment mandate * * @group mollie */ public function testMandates(): void { // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); $response = $this->post("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->put("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->delete("api/v4/payments/mandate"); $response->assertStatus(401); $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->currency = 'EUR'; $wallet->save(); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (amount smaller than the minimum value) $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); $this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']); // Test creating a mandate (negative balance, amount too small) Wallet::where('id', $wallet->id)->update(['balance' => -2000]); $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']); // Test creating a mandate (valid input) - $post = ['amount' => 20.10, 'balance' => 0]; + $post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); // Assert the proper payment amount has been used $payment = Payment::where('id', $json['id'])->first(); $this->assertSame(2010, $payment->amount); $this->assertSame($wallet->id, $payment->wallet_id); $this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ 'cardNumber' => '4242', 'cardLabel' => 'Visa', ], 'customerId' => 'cst_GMfxGPt7Gj', 'createdAt' => '2020-04-28T11:09:47+00:00', ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); Bus::fake(); $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); // Test updating mandate details (invalid input) $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); $this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']); // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockMollie(); // Delete mandate $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been removed.', $json['message']); // Confirm with Mollie the mandate does not exist $customer_id = $wallet->getSetting('mollie_id'); $this->expectException(\Mollie\Api\Exceptions\ApiException::class); $this->expectExceptionMessageMatches('/410: Gone/'); $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); // Test Mollie's "410 Gone" response handling when fetching the mandate info // It is expected to remove the mandate reference $mollie_response = [ 'status' => 410, 'title' => "Gone", 'detail' => "You are trying to access an object, which has previously been deleted", '_links' => [ 'documentation' => [ 'href' => "https://docs.mollie.com/errors", 'type' => "text/html" ] ] ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(410, [], json_encode($mollie_response))); $wallet->fresh()->setSetting('mollie_mandate_id', '123'); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse(array_key_exists('id', $json)); $this->assertFalse(array_key_exists('method', $json)); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); } /** * Test creating a payment and receiving a status via webhook * * @group mollie */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->currency = 'EUR'; $wallet->save(); // Invalid amount $post = ['amount' => -1]; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); $this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']); // Invalid currency $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(500); // Successful payment $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $this->assertSame(1234, $payment->amount); $this->assertSame(1234, $payment->currency_amount); $this->assertSame('EUR', $payment->currency); $this->assertSame($user->tenant->title . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook // Note: Webhook end-point does not require authentication $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Verify "paid -> open -> paid" scenario, assert that balance didn't change $mollie_response['status'] = 'open'; unset($mollie_response['paidAt']); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $mollie_response['status'] = 'paid'; $mollie_response['paidAt'] = date('c'); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame('failed', $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Test automatic payment charges * * @group mollie */ public function testTopUp(): void { Bus::fake(); $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->currency = 'EUR'; $wallet->save(); // Create a valid mandate first (balance=0, so there's no extra payment yet) - $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]); + $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0, 'methodId' => 'creditcard']); $wallet->setSetting('mandate_balance', 10); // Expect a recurring payment as we have a valid mandate at this point // and the balance is below the threshold $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount. // There should be two records, one for the mandate payment and another for // the top-up payment $payments = $wallet->payments()->orderBy('amount')->get(); $this->assertCount(2, $payments); $this->assertSame(0, $payments[0]->amount); $this->assertSame(0, $payments[0]->currency_amount); $this->assertSame(2010, $payments[1]->amount); $this->assertSame(2010, $payments[1]->currency_amount); $payment = $payments[1]; // In mollie we don't have to wait for a webhook, the response to // PaymentIntent already sets the status to 'paid', so we can test // immediately the balance update // Assert that email notification job has been dispatched $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 9399)", $transaction->description ); Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); // Test webhook for recurring payments $wallet->transactions()->delete(); $responseStack = $this->mockMollie(); Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mollie_mandate_id', 'xxx'); $wallet->setSetting('mandate_disabled', null); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook * * @group mollie */ public function testRefundAndChargeback(): void { Bus::fake(); $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->currency = 'EUR'; $wallet->save(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, 'currency_amount' => 123, 'currency' => 'EUR', 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ "currency" => "EUR", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-101, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get(); $this->assertCount(1, $transactions); $this->assertSame(-101, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type); $this->assertSame("refund desc", $transactions[0]->description); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-101, $payments[0]->amount); $this->assertSame(-101, $payments[0]->currency_amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame("refund desc", $payments[0]->description); // Test handling a chargeback by the webhook $mollie_response1["_links"] = [ "chargebacks" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks", "type" => "application/hal+json" ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "chargebacks" => [ [ "resource" => "chargeback", "id" => "chb_123456", "paymentId" => $payment->id, "amount" => [ "currency" => "EUR", "value" => "0.15", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-116, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get(); $this->assertCount(1, $transactions); $this->assertSame(-15, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type); $this->assertSame('', $transactions[0]->description); $payments = $wallet->payments()->where('id', 'chb_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-15, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame('', $payments[0]->description); Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class); $this->unmockMollie(); } /** * Create Mollie's auto-payment mandate using our API and Chrome browser */ protected function createMandate(Wallet $wallet, array $params) { // Use the API to create a first payment with a mandate $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params); $response->assertStatus(200); $json = $response->json(); // There's no easy way to confirm a created mandate. // The only way seems to be to fire up Chrome on checkout page // and do actions with use of Dusk browser. $this->startBrowser()->visit($json['redirectUrl']); $molliePage = new \Tests\Browser\Pages\PaymentMollie(); $molliePage->assert($this->browser); $molliePage->submitPayment($this->browser, 'paid'); $this->stopBrowser(); } /** * Test listing a pending payment * * @group mollie */ public function testListingPayments(): void { Bus::fake(); $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->currency = 'EUR'; $wallet->save(); //Empty response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); // Successful payment $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); //A response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(1, $json['list']); $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']); $this->assertSame('EUR', $json['list'][0]['currency']); $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']); $this->assertSame(1234, $json['list'][0]['amount']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(true, $json['hasPending']); // Set the payment to paid $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $payment->status = PaymentProvider::STATUS_PAID; $payment->save(); // They payment should be gone from the pending list now $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); } /** * Test listing payment methods * * @group mollie */ public function testListingPaymentMethods(): void { Bus::fake(); $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->currency = 'EUR'; $wallet->save(); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); $response->assertStatus(200); $json = $response->json(); $hasCoinbase = !empty(\config('services.coinbase.key')); $this->assertCount(3 + intval($hasCoinbase), $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('paypal', $json[1]['id']); $this->assertSame('banktransfer', $json[2]['id']); $this->assertSame('EUR', $json[0]['currency']); $this->assertSame('EUR', $json[1]['currency']); $this->assertSame('EUR', $json[2]['currency']); $this->assertSame(1, $json[0]['exchangeRate']); $this->assertSame(1, $json[1]['exchangeRate']); $this->assertSame(1, $json[2]['exchangeRate']); if ($hasCoinbase) { $this->assertSame('bitcoin', $json[3]['id']); $this->assertSame('BTC', $json[3]['currency']); } $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('EUR', $json[0]['currency']); } } diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php index e8a35b74..bd407896 100644 --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -1,1080 +1,1080 @@ 'mollie']); Utils::setTestExchangeRates(['EUR' => '0.90503424978382']); $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); Utils::setTestExchangeRates([]); parent::tearDown(); } /** * Test creating/updating/deleting an outo-payment mandate * * @group mollie */ public function testMandates(): void { // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); $response = $this->post("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->put("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->delete("api/v4/payments/mandate"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (amount smaller than the minimum value) $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test creating a mandate (negative balance, amount too small) Wallet::where('id', $wallet->id)->update(['balance' => -2000]); $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']); // Test creating a mandate (valid input) - $post = ['amount' => 20.10, 'balance' => 0]; + $post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); // Assert the proper payment amount has been used $payment = Payment::where('id', $json['id'])->first(); $this->assertSame(2010, $payment->amount); $this->assertSame($wallet->id, $payment->wallet_id); $this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ 'cardNumber' => '4242', 'cardLabel' => 'Visa', ], 'customerId' => 'cst_GMfxGPt7Gj', 'createdAt' => '2020-04-28T11:09:47+00:00', ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); Bus::fake(); $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); // Test updating mandate details (invalid input) $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockMollie(); // Delete mandate $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been removed.', $json['message']); // Confirm with Mollie the mandate does not exist $customer_id = $wallet->getSetting('mollie_id'); $this->expectException(\Mollie\Api\Exceptions\ApiException::class); $this->expectExceptionMessageMatches('/410: Gone/'); $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); // Test Mollie's "410 Gone" response handling when fetching the mandate info // It is expected to remove the mandate reference $mollie_response = [ 'status' => 410, 'title' => "Gone", 'detail' => "You are trying to access an object, which has previously been deleted", '_links' => [ 'documentation' => [ 'href' => "https://docs.mollie.com/errors", 'type' => "text/html" ] ] ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(410, [], json_encode($mollie_response))); $wallet->fresh()->setSetting('mollie_mandate_id', '123'); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse(array_key_exists('id', $json)); $this->assertFalse(array_key_exists('method', $json)); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); } /** * Test creating a payment and receiving a status via webhook * * @group mollie */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Invalid amount $post = ['amount' => -1]; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Invalid currency $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(500); // Successful payment $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $this->assertSame(1234, $payment->amount); $this->assertSame(1234, $payment->currency_amount); $this->assertSame('CHF', $payment->currency); $this->assertSame($user->tenant->title . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook // Note: Webhook end-point does not require authentication $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Verify "paid -> open -> paid" scenario, assert that balance didn't change $mollie_response['status'] = 'open'; unset($mollie_response['paidAt']); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $mollie_response['status'] = 'paid'; $mollie_response['paidAt'] = date('c'); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame('failed', $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Test creating a payment and receiving a status via webhook using a foreign currency * * @group mollie */ public function testStoreAndWebhookForeignCurrency(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Successful payment in EUR $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'banktransfer']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $payment = $wallet->payments() ->where('currency', 'EUR')->get()->last(); $this->assertSame(1234, $payment->amount); $this->assertSame(1117, $payment->currency_amount); $this->assertSame('EUR', $payment->currency); $this->assertEquals(0, $wallet->balance); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); } /** * Test automatic payment charges * * @group mollie */ public function testTopUp(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Create a valid mandate first (balance=0, so there's no extra payment yet) $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]); $wallet->setSetting('mandate_balance', 10); // Expect a recurring payment as we have a valid mandate at this point // and the balance is below the threshold $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount. // There should be two records, one for the mandate payment and another for // the top-up payment $payments = $wallet->payments()->orderBy('amount')->get(); $this->assertCount(2, $payments); $this->assertSame(0, $payments[0]->amount); $this->assertSame(0, $payments[0]->currency_amount); $this->assertSame(2010, $payments[1]->amount); $this->assertSame(2010, $payments[1]->currency_amount); $payment = $payments[1]; // In mollie we don't have to wait for a webhook, the response to // PaymentIntent already sets the status to 'paid', so we can test // immediately the balance update // Assert that email notification job has been dispatched $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 9399)", $transaction->description ); Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); // Test webhook for recurring payments $wallet->transactions()->delete(); $responseStack = $this->mockMollie(); Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mollie_mandate_id', 'xxx'); $wallet->setSetting('mandate_disabled', null); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook * * @group mollie */ public function testRefundAndChargeback(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, 'currency_amount' => 123, 'currency' => 'CHF', 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ "currency" => "CHF", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-101, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get(); $this->assertCount(1, $transactions); $this->assertSame(-101, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type); $this->assertSame("refund desc", $transactions[0]->description); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-101, $payments[0]->amount); $this->assertSame(-101, $payments[0]->currency_amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame("refund desc", $payments[0]->description); // Test handling a chargeback by the webhook $mollie_response1["_links"] = [ "chargebacks" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks", "type" => "application/hal+json" ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "chargebacks" => [ [ "resource" => "chargeback", "id" => "chb_123456", "paymentId" => $payment->id, "amount" => [ "currency" => "CHF", "value" => "0.15", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-116, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get(); $this->assertCount(1, $transactions); $this->assertSame(-15, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type); $this->assertSame('', $transactions[0]->description); $payments = $wallet->payments()->where('id', 'chb_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-15, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame('', $payments[0]->description); Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook in a foreign currency * * @group mollie */ public function testRefundAndChargebackForeignCurrency(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 1234, 'currency_amount' => 1117, 'currency' => 'EUR', 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ "currency" => "EUR", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertTrue($wallet->balance <= -100); $this->assertTrue($wallet->balance >= -114); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertTrue($payments[0]->amount <= -100); $this->assertTrue($payments[0]->amount >= -114); $this->assertSame(-101, $payments[0]->currency_amount); $this->assertSame('EUR', $payments[0]->currency); $this->unmockMollie(); } /** * Create Mollie's auto-payment mandate using our API and Chrome browser */ protected function createMandate(Wallet $wallet, array $params) { // Use the API to create a first payment with a mandate $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params); $response->assertStatus(200); $json = $response->json(); // There's no easy way to confirm a created mandate. // The only way seems to be to fire up Chrome on checkout page // and do actions with use of Dusk browser. $this->startBrowser()->visit($json['redirectUrl']); $molliePage = new \Tests\Browser\Pages\PaymentMollie(); $molliePage->assert($this->browser); $molliePage->submitPayment($this->browser, 'paid'); $this->stopBrowser(); } /** * Test listing a pending payment * * @group mollie */ public function testListingPayments(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); //Empty response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); $wallet = $user->wallets()->first(); // Successful payment $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); //A response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(1, $json['list']); $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']); $this->assertSame('CHF', $json['list'][0]['currency']); $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']); $this->assertSame(1234, $json['list'][0]['amount']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(true, $json['hasPending']); // Set the payment to paid $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $payment->status = PaymentProvider::STATUS_PAID; $payment->save(); // They payment should be gone from the pending list now $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); } /** * Test listing payment methods * * @group mollie */ public function testListingPaymentMethods(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); $response->assertStatus(200); $json = $response->json(); $hasCoinbase = !empty(\config('services.coinbase.key')); $this->assertCount(3 + intval($hasCoinbase), $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('paypal', $json[1]['id']); $this->assertSame('banktransfer', $json[2]['id']); $this->assertSame('CHF', $json[0]['currency']); $this->assertSame('CHF', $json[1]['currency']); $this->assertSame('EUR', $json[2]['currency']); if ($hasCoinbase) { $this->assertSame('bitcoin', $json[3]['id']); $this->assertSame('BTC', $json[3]['currency']); } $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('CHF', $json[0]['currency']); } } diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php index 06e0841b..44b51d8b 100644 --- a/src/tests/Feature/Controller/PaymentsStripeTest.php +++ b/src/tests/Feature/Controller/PaymentsStripeTest.php @@ -1,745 +1,745 @@ 'stripe']); $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_CREDIT)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_CREDIT)->delete(); parent::tearDown(); } /** * Test creating/updating/deleting an outo-payment mandate * * @group stripe */ public function testMandates(): void { Bus::fake(); // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); $response = $this->post("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->put("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->delete("api/v4/payments/mandate"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test creating a mandate (negative balance, amount too small) Wallet::where('id', $wallet->id)->update(['balance' => -2000]); $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']); // Test creating a mandate (valid input) - $post = ['amount' => 20.10, 'balance' => 0]; + $post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^cs_test_|', $json['id']); // Assert the proper payment amount has been used // Stripe in 'setup' mode does not allow to set the amount $payment = Payment::where('wallet_id', $wallet->id)->first(); $this->assertSame(0, $payment->amount); $this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertSame(false, $json['isDisabled']); // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $setupIntent = '{ "id": "AAA", "object": "setup_intent", "created": 123456789, "payment_method": "pm_YYY", "status": "succeeded", "usage": "off_session", "customer": null }'; $paymentMethod = '{ "id": "pm_YYY", "object": "payment_method", "card": { "brand": "visa", "country": "US", "last4": "4242" }, "created": 123456789, "type": "card" }'; $client = $this->mockStripe(); $client->addResponse($setupIntent); $client->addResponse($paymentMethod); // As we do not use checkout page, we do not receive a webworker request // I.e. we have to fake the mandate id $wallet = $user->wallets()->first(); $wallet->setSetting('stripe_mandate_id', 'AAA'); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); // Test updating mandate details (invalid input) $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $user->refresh(); $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test updating a mandate (valid input) $client->addResponse($setupIntent); $client->addResponse($paymentMethod); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); $this->assertSame('AAA', $json['id']); $this->assertFalse($json['isDisabled']); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $client->addResponse($setupIntent); $client->addResponse($paymentMethod); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame('AAA', $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockStripe(); // TODO: Delete mandate } /** * Test creating a payment and receiving a status via webhook * * @group stripe */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $post = ['amount' => -1]; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Invalid currency $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(500); // Successful payment $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^cs_test_|', $json['id']); $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $this->assertSame(1234, $payment->amount); $this->assertSame($user->tenant->title . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook $post = [ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", 'object' => "event", 'api_version' => "2020-03-02", 'created' => 1590147209, 'data' => [ 'object' => [ 'id' => $payment->id, 'object' => "payment_intent", 'amount' => 1234, 'amount_capturable' => 0, 'amount_received' => 1234, 'capture_method' => "automatic", 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48", 'confirmation_method' => "automatic", 'created' => 1590147204, 'currency' => "chf", 'customer' => "cus_HKDZ53OsKdlM83", 'last_payment_error' => null, 'livemode' => false, 'metadata' => [], 'receipt_email' => "payment-test@kolabnow.com", 'status' => "succeeded" ] ], 'type' => "payment_intent.succeeded" ]; // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Stripe", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Test that balance didn't change if the same event is posted $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure ('failed' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $post['type'] = "payment_intent.payment_failed"; $post['data']['object']['status'] = 'failed'; $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Test for payment failure ('canceled' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $post['type'] = "payment_intent.canceled"; $post['data']['object']['status'] = 'canceled'; $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Test receiving webhook request for setup intent * * @group stripe */ public function testCreateMandateAndWebhook(): void { $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); Wallet::where('id', $wallet->id)->update(['balance' => -1000]); // Test creating a mandate (valid input) $post = ['amount' => 20.10, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $payment = $wallet->payments()->first(); $this->assertSame(PaymentProvider::STATUS_OPEN, $payment->status); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); $this->assertSame(0, $payment->amount); $post = [ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", 'object' => "event", 'api_version' => "2020-03-02", 'created' => 1590147209, 'data' => [ 'object' => [ 'id' => $payment->id, 'object' => "setup_intent", 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48", 'created' => 1590147204, 'customer' => "cus_HKDZ53OsKdlM83", 'last_setup_error' => null, 'metadata' => [], 'status' => "succeeded" ] ], 'type' => "setup_intent.succeeded" ]; Bus::fake(); // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); $payment->refresh(); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertSame($payment->id, $wallet->fresh()->getSetting('stripe_mandate_id')); // Expect a WalletCharge job if the balance is negative Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // TODO: test other setup_intent.* events } /** * Test automatic payment charges * * @group stripe */ public function testTopUpAndWebhook(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Stripe API does not allow us to create a mandate easily // That's why we we'll mock API responses // Create a fake mandate $wallet->setSettings([ 'mandate_amount' => 20.10, 'mandate_balance' => 10, 'stripe_mandate_id' => 'AAA', ]); $setupIntent = json_encode([ "id" => "AAA", "object" => "setup_intent", "created" => 123456789, "payment_method" => "pm_YYY", "status" => "succeeded", "usage" => "off_session", "customer" => null ]); $paymentMethod = json_encode([ "id" => "pm_YYY", "object" => "payment_method", "card" => [ "brand" => "visa", "country" => "US", "last4" => "4242" ], "created" => 123456789, "type" => "card" ]); $paymentIntent = json_encode([ "id" => "pi_XX", "object" => "payment_intent", "created" => 123456789, "amount" => 2010, "currency" => "chf", "description" => $user->tenant->title . " Recurring Payment" ]); $client = $this->mockStripe(); $client->addResponse($setupIntent); $client->addResponse($paymentMethod); $client->addResponse($setupIntent); $client->addResponse($paymentIntent); $client->addResponse($setupIntent); $client->addResponse($paymentMethod); // Expect a recurring payment as we have a valid mandate at this point $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount // There should be two records, one for the first payment and another for // the recurring payment $this->assertCount(1, $wallet->payments()->get()); $payment = $wallet->payments()->first(); $this->assertSame(2010, $payment->amount); $this->assertSame($user->tenant->title . " Recurring Payment", $payment->description); $this->assertSame("pi_XX", $payment->id); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); $this->unmockStripe(); // Test webhook $post = [ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", 'object' => "event", 'api_version' => "2020-03-02", 'created' => 1590147209, 'data' => [ 'object' => [ 'id' => $payment->id, 'object' => "payment_intent", 'amount' => 2010, 'capture_method' => "automatic", 'created' => 1590147204, 'currency' => "chf", 'customer' => "cus_HKDZ53OsKdlM83", 'last_payment_error' => null, 'metadata' => [], 'receipt_email' => "payment-test@kolabnow.com", 'status' => "succeeded" ] ], 'type' => "payment_intent.succeeded" ]; // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Stripe", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure ('failed' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mandate_disabled', null); $post['type'] = "payment_intent.payment_failed"; $post['data']['object']['status'] = 'failed'; $response = $this->webhookRequest($post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure ('canceled' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $post['type'] = "payment_intent.canceled"; $post['data']['object']['status'] = 'canceled'; $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Generate Stripe-Signature header for a webhook payload */ protected function webhookRequest($post) { $secret = \config('services.stripe.webhook_secret'); $ts = time(); $payload = "$ts." . json_encode($post); $sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret)); return $this->withHeaders(['Stripe-Signature' => $sig]) ->json('POST', "api/webhooks/payment/stripe", $post); } /** * Test listing payment methods * * @group stripe */ public function testListingPaymentMethods(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); $response->assertStatus(200); $json = $response->json(); $hasCoinbase = !empty(\config('services.coinbase.key')); $this->assertCount(2 + intval($hasCoinbase), $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('paypal', $json[1]['id']); $this->assertSame('bitcoin', $json[2]['id']); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('creditcard', $json[0]['id']); } }