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,28 +40,10 @@ $current_user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $current_user->wallets->first(); + $wallet = $current_user->wallets()->first(); - $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 response()->json(['status' => 'error', 'errors' => $v->errors()], 422); - } - - $amount = (int) ($request->amount * 100); - - // Validate the minimum value - if ($amount < PaymentProvider::MIN_AMOUNT) { - $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; - $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + // Input validation + if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } @@ -70,15 +52,20 @@ 'mandate_balance' => $request->balance, ]); - $request = [ + $mandate = [ 'currency' => 'CHF', - 'amount' => $amount, 'description' => \config('app.name') . ' Auto-Payment Setup', ]; + // 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, $request); + $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; @@ -95,7 +82,7 @@ $user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $user->wallets->first(); + $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); @@ -121,8 +108,43 @@ $current_user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $current_user->wallets->first(); + $wallet = $current_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', @@ -134,43 +156,27 @@ // TODO: allow comma as a decimal point? if ($v->fails()) { - return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + return $v->errors(); } $amount = (int) ($request->amount * 100); // Validate the minimum value - if ($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); + // 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 + ) { + return ['amount' => \trans('validation.minamountdebt')]; } - // If the mandate is disabled the update will trigger - // an auto-payment and the amount must cover the debt - if ($wallet->getSetting('mandate_disabled')) { - if ($wallet->balance < 0 && $wallet->balance + $amount < 0) { - $errors = ['amount' => \trans('validation.minamountdebt')]; - return response()->json(['status' => 'error', 'errors' => $errors], 422); - } - - $wallet->setSetting('mandate_disabled', null); - - if ($wallet->balance < intval($request->balance * 100)) { - \App\Jobs\WalletCharge::dispatch($wallet); - } + if ($amount < PaymentProvider::MIN_AMOUNT) { + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + return ['amount' => \trans('validation.minamount', ['amount' => $min])]; } - $wallet->setSettings([ - 'mandate_amount' => $request->amount, - 'mandate_balance' => $request->balance, - ]); - - $result = self::walletMandate($wallet); - $result['status'] = 'success'; - $result['message'] = \trans('app.mandate-update-success'); - - return response()->json($result); + return null; } /** @@ -185,7 +191,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,12 @@ if ($status == self::STATUS_PAID) { $payment->wallet->setSetting('stripe_mandate_id', $intent->id); + $threshold = intval($payment->wallet->getSetting('mandate_balance') * 100); + + // Top-up the wallet if balance is below the threshold + if ($payment->wallet->balance < $threshold && $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); @@ -176,6 +196,8 @@ Bus::fake(); $wallet->setSetting('mandate_disabled', null); + $wallet->balance = 1000; + $wallet->save(); // Test updating mandate details (invalid input) $post = []; @@ -202,7 +224,7 @@ // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); - $post = ['amount' => 30.10, 'balance' => 1]; + $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); @@ -216,7 +238,9 @@ $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); - $this->assertEquals(1, $wallet->getSetting('mandate_balance')); + $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); @@ -449,19 +473,24 @@ $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); - // Create a valid mandate first - $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 10]); + // 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 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 +517,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 +525,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 +533,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 +547,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); @@ -176,6 +196,8 @@ // 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); @@ -202,7 +224,7 @@ $client->addResponse($setupIntent); $client->addResponse($paymentMethod); - $post = ['amount' => 30.10, 'balance' => 1]; + $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); @@ -211,7 +233,7 @@ $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(1, $wallet->getSetting('mandate_balance')); + $this->assertEquals(10, $wallet->getSetting('mandate_balance')); $this->assertSame('AAA', $json['id']); $this->assertFalse($json['isDisabled']); @@ -406,6 +428,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 +461,8 @@ 'type' => "setup_intent.succeeded" ]; + Bus::fake(); + // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); @@ -447,6 +472,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'])); }