diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -21,7 +21,7 @@ $user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $user->wallets->first(); + $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); @@ -40,7 +40,7 @@ $current_user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $current_user->wallets->first(); + $wallet = $current_user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', @@ -59,7 +59,15 @@ $amount = (int) ($request->amount * 100); // Validate the minimum value - if ($amount < PaymentProvider::MIN_AMOUNT) { + // It has to be at least minimum payment amount and must cover current debt + if ( + $wallet->balance < 0 + && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT + && $wallet->balance + $amount < 0 + ) { + $errors = ['amount' => \trans('validation.minamountdebt')]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); + } elseif ($amount < PaymentProvider::MIN_AMOUNT) { $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); @@ -72,10 +80,15 @@ $request = [ 'currency' => 'CHF', - 'amount' => $amount, 'description' => \config('app.name') . ' Auto-Payment Setup', ]; + // Normally the auto-payment operation is 0, if the balance is below 0, + // we'll charge for the mandate amount + if ($wallet->balance < 0) { + $request['amount'] = $amount; + } + $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $request); @@ -95,7 +108,7 @@ $user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $user->wallets->first(); + $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); @@ -121,7 +134,7 @@ $current_user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $current_user->wallets->first(); + $wallet = $current_user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', @@ -185,7 +198,7 @@ $current_user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $current_user->wallets->first(); + $wallet = $current_user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -37,7 +37,7 @@ * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: - * - amount: Value in cents + * - amount: Value in cents (optional) * - currency: The operation currency * - description: Operation desc. * @@ -50,10 +50,14 @@ // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); + if (!isset($payment['amount'])) { + $payment['amount'] = 0; + } + $request = [ 'amount' => [ 'currency' => $payment['currency'], - 'value' => '0.00', + 'value' => sprintf('%.2f', $payment['amount'] / 100), ], 'customerId' => $customer_id, 'sequenceType' => 'first', @@ -71,6 +75,13 @@ $wallet->setSetting('mollie_mandate_id', $response->mandateId); } + // Store the payment reference in database + $payment['status'] = $response->status; + $payment['id'] = $response->id; + $payment['type'] = self::TYPE_MANDATE; + + $this->storePayment($payment, $wallet->id); + return [ 'id' => $response->id, 'redirectUrl' => $response->getCheckoutUrl(), diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -56,7 +56,7 @@ * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: - * - amount: Value in cents + * - amount: Value in cents (not used) * - currency: The operation currency * - description: Operation desc. * @@ -77,12 +77,14 @@ 'mode' => 'setup', ]; + // Note: Stripe does not allow to set amount for 'setup' operation + // We'll dispatch WalletCharge job when we receive a webhook request + $session = StripeAPI\Checkout\Session::create($request); - $payment = [ - 'id' => $session->setup_intent, - 'type' => self::TYPE_MANDATE, - ]; + $payment['amount'] = 0; + $payment['id'] = $session->setup_intent; + $payment['type'] = self::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); @@ -355,6 +357,11 @@ if ($status == self::STATUS_PAID) { $payment->wallet->setSetting('stripe_mandate_id', $intent->id); + + // Update the balance, if it wasn't already + if ($payment->wallet->balance < 0 && $payment->status != self::STATUS_PAID) { + \App\Jobs\WalletCharge::dispatch($payment->wallet); + } } $payment->status = $status; diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -80,6 +80,7 @@ $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); // Test creating a mandate (invalid input) $post = []; @@ -104,7 +105,7 @@ $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); - // Test creating a mandate (invalid input) + // 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); @@ -116,6 +117,18 @@ $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $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]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); @@ -126,6 +139,13 @@ $this->assertSame('success', $json['status']); $this->assertRegExp('|^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("Kolab Now 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); @@ -456,12 +476,14 @@ $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); + // 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(2010, $payments[1]->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 @@ -488,7 +510,7 @@ $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); - $this->assertCount(1, $wallet->payments()->get()); + $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); @@ -496,7 +518,7 @@ $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); - $this->assertCount(1, $wallet->payments()->get()); + $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); @@ -504,7 +526,7 @@ $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); - $this->assertCount(1, $wallet->payments()->get()); + $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { @@ -518,7 +540,7 @@ $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); - $this->assertCount(1, $wallet->payments()->get()); + $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php --- a/src/tests/Feature/Controller/PaymentsStripeTest.php +++ b/src/tests/Feature/Controller/PaymentsStripeTest.php @@ -72,6 +72,7 @@ $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); // Test creating a mandate (invalid input) $post = []; @@ -108,6 +109,18 @@ $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; $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]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); @@ -118,6 +131,13 @@ $this->assertSame('success', $json['status']); $this->assertRegExp('|^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("Kolab Now 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); @@ -406,6 +426,7 @@ { $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]; @@ -438,6 +459,8 @@ 'type' => "setup_intent.succeeded" ]; + Bus::fake(); + // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); @@ -447,6 +470,13 @@ $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 } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -190,6 +190,8 @@ $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); + $wallet->balance = -100; + $wallet->save(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); @@ -209,7 +211,6 @@ $this->assertSame('CHF', $json['currency']); $this->assertSame($wallet->balance, $json['balance']); $this->assertTrue(empty($json['description'])); - // TODO: This assertion does not work after a longer while from seeding $this->assertTrue(!empty($json['notice'])); }