Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117441879
D1900.1774835859.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
22 KB
Referenced Files
None
Subscribers
None
D1900.1774835859.diff
View Options
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']));
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Mon, Mar 30, 1:57 AM (6 d, 16 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18786564
Default Alt Text
D1900.1774835859.diff (22 KB)
Attached To
Mode
D1900: Trigger auto-payment immediately after configuration
Attached
Detach File
Event Timeline