Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117744170
D1231.1775169528.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
161 KB
Referenced Files
None
Subscribers
None
D1231.1775169528.diff
View Options
diff --git a/src/.env.example b/src/.env.example
--- a/src/.env.example
+++ b/src/.env.example
@@ -60,7 +60,11 @@
SWOOLE_HTTP_HOST=127.0.0.1
SWOOLE_HTTP_PORT=8000
+PAYMENT_PROVIDER=
MOLLIE_KEY=
+STRIPE_KEY=
+STRIPE_PUBLIC_KEY=
+STRIPE_WEBHOOK_SECRET=
MAIL_DRIVER=smtp
MAIL_HOST=smtp.mailtrap.io
diff --git a/src/app/Console/Commands/MollieInfo.php b/src/app/Console/Commands/MollieInfo.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/MollieInfo.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\User;
+use Illuminate\Console\Command;
+
+class MollieInfo extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'mollie:info {user?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Mollie information';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ if ($this->argument('user')) {
+ $user = User::where('email', $this->argument('user'))->first();
+
+ if (!$user) {
+ return 1;
+ }
+
+ $this->info("Found user: {$user->id}");
+
+ $wallet = $user->wallets->first();
+ $provider = new \App\Providers\Payment\Mollie();
+
+ if ($mandate = $provider->getMandate($wallet)) {
+ $amount = $wallet->getSetting('mandate_amount');
+ $balance = $wallet->getSetting('mandate_balance') ?: 0;
+
+ $this->info("Auto-payment: {$mandate['method']}");
+ $this->info(" id: {$mandate['id']}");
+ $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid'));
+ $this->info(" amount: {$amount} {$wallet->currency}");
+ $this->info(" min-balance: {$balance} {$wallet->currency}");
+ } else {
+ $this->info("Auto-payment: none");
+ }
+
+ // TODO: List user payments history
+ } else {
+ $this->info("Available payment methods:");
+
+ foreach (mollie()->methods()->all() as $method) {
+ $this->info("- {$method->description} ({$method->id}):");
+ $this->info(" status: {$method->status}");
+ $this->info(sprintf(
+ " min: %s %s",
+ $method->minimumAmount->value,
+ $method->minimumAmount->currency
+ ));
+ if (!empty($method->maximumAmount)) {
+ $this->info(sprintf(
+ " max: %s %s",
+ $method->maximumAmount->value,
+ $method->maximumAmount->currency
+ ));
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/Console/Commands/StripeInfo.php b/src/app/Console/Commands/StripeInfo.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/StripeInfo.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Providers\PaymentProvider;
+use App\User;
+use Illuminate\Console\Command;
+use Stripe as StripeAPI;
+
+class StripeInfo extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'stripe:info {user?}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Stripe information';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ if ($this->argument('user')) {
+ $user = User::where('email', $this->argument('user'))->first();
+
+ if (!$user) {
+ return 1;
+ }
+
+ $this->info("Found user: {$user->id}");
+
+ $wallet = $user->wallets->first();
+ $provider = PaymentProvider::factory('stripe');
+
+ if ($mandate = $provider->getMandate($wallet)) {
+ $amount = $wallet->getSetting('mandate_amount');
+ $balance = $wallet->getSetting('mandate_balance') ?: 0;
+
+ $this->info("Auto-payment: {$mandate['method']}");
+ $this->info(" id: {$mandate['id']}");
+ $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid'));
+ $this->info(" amount: {$amount} {$wallet->currency}");
+ $this->info(" min-balance: {$balance} {$wallet->currency}");
+ } else {
+ $this->info("Auto-payment: none");
+ }
+
+ // TODO: List user payments history
+ } else {
+ // TODO: Fetch some info/stats from Stripe
+ }
+ }
+}
diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php
--- a/src/app/Http/Controllers/API/AuthController.php
+++ b/src/app/Http/Controllers/API/AuthController.php
@@ -31,6 +31,7 @@
*/
public static function logonResponse(User $user)
{
+ // @phpstan-ignore-next-line
$token = Auth::guard()->login($user);
return response()->json([
@@ -101,6 +102,7 @@
*/
public function refresh()
{
+ // @phpstan-ignore-next-line
return $this->respondWithToken(Auth::guard()->refresh());
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -3,12 +3,49 @@
namespace App\Http\Controllers\API\V4\Admin;
use App\Discount;
+use App\Http\Controllers\API\V4\PaymentsController;
+use App\Providers\PaymentProvider;
use App\Wallet;
use Illuminate\Http\Request;
class WalletsController extends \App\Http\Controllers\API\V4\WalletsController
{
/**
+ * Return data of the specified wallet.
+ *
+ * @param string $id A wallet identifier
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function show($id)
+ {
+ $wallet = Wallet::find($id);
+
+ if (empty($wallet)) {
+ return $this->errorResponse(404);
+ }
+
+ $result = $wallet->toArray();
+
+ $result['discount'] = 0;
+ $result['discount_description'] = '';
+
+ if ($wallet->discount) {
+ $result['discount'] = $wallet->discount->discount;
+ $result['discount_description'] = $wallet->discount->description;
+ }
+
+ $result['mandate'] = PaymentsController::walletMandate($wallet);
+
+ $provider = PaymentProvider::factory($wallet);
+
+ $result['provider'] = $provider->name();
+ $result['providerLink'] = $provider->customerLink($wallet);
+
+ return $result;
+ }
+
+ /**
* Update wallet data.
*
* @param \Illuminate\Http\Request $request The API request.
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
@@ -2,9 +2,9 @@
namespace App\Http\Controllers\API\V4;
-use App\Payment;
-use App\Wallet;
use App\Http\Controllers\Controller;
+use App\Providers\PaymentProvider;
+use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
@@ -12,209 +12,264 @@
class PaymentsController extends Controller
{
/**
- * Create a new payment.
+ * Get the auto-payment mandate info.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function mandate()
+ {
+ $user = Auth::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 store(Request $request)
+ public function mandateCreate(Request $request)
{
$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(),
- [
- 'amount' => 'required|int|min:1',
- ]
- );
+ $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);
}
- // Register the user in Mollie, if not yet done
- // FIXME: Maybe Mollie ID should be bound to a wallet, but then
- // The same customer could technicly have multiple
- // Mollie IDs, then we'd need to use some "virtual" email
- // address (e.g. <wallet-id>@<user-domain>) instead of the user email address
- $customer_id = $current_user->getSetting('mollie_id');
- $seq_type = 'oneoff';
-
- if (empty($customer_id)) {
- $customer = mollie()->customers()->create([
- 'name' => $current_user->name(),
- 'email' => $current_user->email,
- ]);
-
- $seq_type = 'first';
- $customer_id = $customer->id;
- $current_user->setSetting('mollie_id', $customer_id);
+ $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);
}
- $payment_request = [
- 'amount' => [
- 'currency' => 'CHF',
- // a number with two decimals is required
- 'value' => sprintf('%.2f', $request->amount / 100),
- ],
- 'customerId' => $customer_id,
- 'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring'
- 'description' => 'Kolab Now Payment', // required
- 'redirectUrl' => \url('/wallet'), // required for non-recurring payments
- 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
- 'locale' => 'en_US',
+ $wallet->setSetting('mandate_amount', $request->amount);
+ $wallet->setSetting('mandate_balance', $request->balance);
+
+ $request = [
+ 'currency' => 'CHF',
+ 'amount' => $amount,
+ 'description' => \config('app.name') . ' Auto-Payment Setup',
];
- // Create the payment in Mollie
- $payment = mollie()->payments()->create($payment_request);
+ $provider = PaymentProvider::factory($wallet);
+
+ $result = $provider->createMandate($wallet, $request);
+
+ $result['status'] = 'success';
+
+ return response()->json($result);
+ }
+
+ /**
+ * Revoke the auto-payment mandate.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function mandateDelete()
+ {
+ $user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets->first();
+
+ $provider = PaymentProvider::factory($wallet);
- // Store the payment reference in database
- self::storePayment($payment, $wallet->id, $request->amount);
+ $provider->deleteMandate($wallet);
return response()->json([
'status' => 'success',
- 'redirectUrl' => $payment->getCheckoutUrl(),
+ 'message' => \trans('app.mandate-delete-success'),
]);
}
/**
- * Update payment status (and balance).
+ * Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
- * @return \Illuminate\Http\Response The response
+ * @return \Illuminate\Http\JsonResponse The response
*/
- public function webhook(Request $request)
+ public function mandateUpdate(Request $request)
{
- $db_payment = Payment::find($request->id);
+ $current_user = Auth::guard()->user();
- // Mollie recommends to return "200 OK" even if the payment does not exist
- if (empty($db_payment)) {
- return response('Success', 200);
- }
+ // TODO: Wallet selection
+ $wallet = $current_user->wallets->first();
- // Get the payment details from Mollie
- $payment = mollie()->payments()->get($request->id);
+ $rules = [
+ 'amount' => 'required|numeric',
+ 'balance' => 'required|numeric|min:0',
+ ];
- if (empty($payment)) {
- return response('Success', 200);
- }
+ // Check required fields
+ $v = Validator::make($request->all(), $rules);
- if ($payment->isPaid()) {
- if (!$payment->hasRefunds() && !$payment->hasChargebacks()) {
- // The payment is paid and isn't refunded or charged back.
- // Update the balance, if it wasn't already
- if ($db_payment->status != 'paid') {
- $db_payment->wallet->credit($db_payment->amount);
- }
- } elseif ($payment->hasRefunds()) {
- // The payment has been (partially) refunded.
- // The status of the payment is still "paid"
- // TODO: Update balance
- } elseif ($payment->hasChargebacks()) {
- // The payment has been (partially) charged back.
- // The status of the payment is still "paid"
- // TODO: Update balance
- }
+ // TODO: allow comma as a decimal point?
+
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- // This is a sanity check, just in case the payment provider api
- // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid.
- if ($db_payment->status != 'paid') {
- $db_payment->status = $payment->status;
- $db_payment->save();
+ $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);
}
- return response('Success', 200);
+ $wallet->setSetting('mandate_amount', $request->amount);
+ $wallet->setSetting('mandate_balance', $request->balance);
+
+ return response()->json([
+ 'status' => 'success',
+ 'message' => \trans('app.mandate-update-success'),
+ ]);
}
/**
- * Charge a wallet with a "recurring" payment.
+ * Create a new payment.
*
- * @param \App\Wallet $wallet The wallet to charge
- * @param int $amount The amount of money in cents
+ * @param \Illuminate\Http\Request $request The API request.
*
- * @return bool
+ * @return \Illuminate\Http\JsonResponse The response
*/
- public static function directCharge(Wallet $wallet, $amount): bool
+ public function store(Request $request)
{
- $customer_id = $wallet->owner->getSetting('mollie_id');
+ $current_user = Auth::guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $current_user->wallets->first();
+
+ $rules = [
+ 'amount' => 'required|numeric',
+ ];
+
+ // Check required fields
+ $v = Validator::make($request->all(), $rules);
+
+ // TODO: allow comma as a decimal point?
- if (empty($customer_id)) {
- return false;
+ if ($v->fails()) {
+ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
- // Check if there's at least one valid mandate
- $mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) {
- return $mandate->isValid();
- });
+ $amount = (int) ($request->amount * 100);
- if (empty($mandates)) {
- return false;
+ // 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);
}
- $payment_request = [
- 'amount' => [
- 'currency' => 'CHF',
- // a number with two decimals is required
- 'value' => sprintf('%.2f', $amount / 100),
- ],
- 'customerId' => $customer_id,
- 'sequenceType' => 'recurring',
- 'description' => 'Kolab Now Recurring Payment',
- 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'),
+ $request = [
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'currency' => 'CHF',
+ 'amount' => $amount,
+ 'description' => \config('app.name') . ' Payment',
];
- // Create the payment in Mollie
- $payment = mollie()->payments()->create($payment_request);
+ $provider = PaymentProvider::factory($wallet);
+
+ $result = $provider->payment($wallet, $request);
+
+ $result['status'] = 'success';
- // Store the payment reference in database
- self::storePayment($payment, $wallet->id, $amount);
+ return response()->json($result);
+ }
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @param string $provider Provider name
+ *
+ * @return \Illuminate\Http\Response The response
+ */
+ public function webhook($provider)
+ {
+ $code = 200;
- return true;
+ if ($provider = PaymentProvider::factory($provider)) {
+ $code = $provider->webhook();
+ }
+
+ return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
- * Create self URL
+ * Charge a wallet with a "recurring" payment.
*
- * @param string $route Route/Path
+ * @param \App\Wallet $wallet The wallet to charge
+ * @param int $amount The amount of money in cents
*
- * @return string Full URL
+ * @return bool
*/
- protected static function serviceUrl(string $route): string
+ public static function directCharge(Wallet $wallet, $amount): bool
{
- $url = \url($route);
+ $request = [
+ 'type' => PaymentProvider::TYPE_RECURRING,
+ 'currency' => 'CHF',
+ 'amount' => $amount,
+ 'description' => \config('app.name') . ' Recurring Payment',
+ ];
- $app_url = trim(\config('app.url'), '/');
- $pub_url = trim(\config('app.public_url'), '/');
+ $provider = PaymentProvider::factory($wallet);
- if ($pub_url != $app_url) {
- $url = str_replace($app_url, $pub_url, $url);
+ if ($result = $provider->payment($wallet, $request)) {
+ return true;
}
- return $url;
+ return false;
}
/**
- * Create a payment record in DB
+ * Returns auto-payment mandate info for the specified wallet
+ *
+ * @param \App\Wallet $wallet A wallet object
*
- * @param object $payment Mollie payment
- * @param string $wallet_id Wallet ID
- * @param int $amount Amount of money in cents
+ * @return array A mandate metadata
*/
- protected static function storePayment($payment, $wallet_id, $amount): void
+ public static function walletMandate(Wallet $wallet): array
{
- $db_payment = new Payment();
- $db_payment->id = $payment->id;
- $db_payment->description = $payment->description;
- $db_payment->status = $payment->status;
- $db_payment->amount = $amount;
- $db_payment->wallet_id = $wallet_id;
- $db_payment->save();
+ $provider = PaymentProvider::factory($wallet);
+
+ // Get the Mandate info
+ $mandate = (array) $provider->getMandate($wallet);
+
+ $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
+ $mandate['balance'] = 0;
+
+ foreach (['amount', 'balance'] as $key) {
+ if (($value = $wallet->getSetting("mandate_{$key}")) !== null) {
+ $mandate[$key] = $value;
+ }
+ }
+
+ return $mandate;
}
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -426,7 +426,7 @@
$response = array_merge($response, self::userStatuses($user));
- // Add discount info to wallet object output
+ // Add more info to the wallet object output
$map_func = function ($wallet) use ($user) {
$result = $wallet->toArray();
@@ -439,6 +439,9 @@
$result['user_email'] = $wallet->owner->email;
}
+ $provider = \App\Providers\PaymentProvider::factory($wallet);
+ $result['provider'] = $provider->name();
+
return $result;
};
diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php
--- a/src/app/Http/Kernel.php
+++ b/src/app/Http/Kernel.php
@@ -19,7 +19,8 @@
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
- \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class
+ \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
+ \App\Http\Middleware\DevelConfig::class,
];
/**
diff --git a/src/app/Http/Middleware/DevelConfig.php b/src/app/Http/Middleware/DevelConfig.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Middleware/DevelConfig.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+
+class DevelConfig
+{
+ /**
+ * Handle an incoming request.
+ *
+ * @param \Illuminate\Http\Request $request
+ * @param \Closure $next
+ *
+ * @return mixed
+ */
+ public function handle($request, Closure $next)
+ {
+ // Only in testing/local environment...
+ if (\App::environment('local')) {
+ // This is the only way I found to change configuration
+ // on a running application. We need this to browser-test both
+ // Mollie and Stripe providers without .env file modification
+ // and artisan restart
+ if ($request->getMethod() == 'GET' && isset($request->paymentProvider)) {
+ $provider = $request->paymentProvider;
+ } else {
+ $provider = $request->headers->get('X-TEST-PAYMENT-PROVIDER');
+ }
+
+ if (!empty($provider)) {
+ \config(['services.payment_provider' => $provider]);
+ }
+ }
+
+ return $next($request);
+ }
+}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
new file mode 100644
--- /dev/null
+++ b/src/app/Providers/Payment/Mollie.php
@@ -0,0 +1,349 @@
+<?php
+
+namespace App\Providers\Payment;
+
+use App\Payment;
+use App\Utils;
+use App\Wallet;
+
+class Mollie extends \App\Providers\PaymentProvider
+{
+ /**
+ * Get a link to the customer in the provider's control panel
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string|null The string representing <a> tag
+ */
+ public function customerLink(Wallet $wallet): ?string
+ {
+ $customer_id = self::mollieCustomerId($wallet);
+
+ return sprintf(
+ '<a href="https://www.mollie.com/dashboard/customers/%s" target="_blank">%s</a>',
+ $customer_id,
+ $customer_id
+ );
+ }
+
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ public function createMandate(Wallet $wallet, array $payment): ?array
+ {
+ // Register the user in Mollie, if not yet done
+ $customer_id = self::mollieCustomerId($wallet);
+
+ $request = [
+ 'amount' => [
+ 'currency' => $payment['currency'],
+ 'value' => '0.00',
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => 'first',
+ 'description' => $payment['description'],
+ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
+ 'redirectUrl' => \url('/wallet'),
+ 'locale' => 'en_US',
+ // 'method' => 'creditcard',
+ ];
+
+ // Create the payment in Mollie
+ $response = mollie()->payments()->create($request);
+
+ if ($response->mandateId) {
+ $wallet->setSetting('mollie_mandate_id', $response->mandateId);
+ }
+
+ return [
+ 'id' => $response->id,
+ 'redirectUrl' => $response->getCheckoutUrl(),
+ ];
+ }
+
+ /**
+ * Revoke the auto-payment mandate for the wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return bool True on success, False on failure
+ */
+ public function deleteMandate(Wallet $wallet): bool
+ {
+ // Get the Mandate info
+ $mandate = self::mollieMandate($wallet);
+
+ // Revoke the mandate on Mollie
+ if ($mandate) {
+ $mandate->revoke();
+
+ $wallet->setSetting('mollie_mandate_id', null);
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a auto-payment mandate for the wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ public function getMandate(Wallet $wallet): ?array
+ {
+ // Get the Mandate info
+ $mandate = self::mollieMandate($wallet);
+
+ if (empty($mandate)) {
+ return null;
+ }
+
+ $result = [
+ 'id' => $mandate->id,
+ 'isPending' => $mandate->isPending(),
+ 'isValid' => $mandate->isValid(),
+ ];
+
+ $details = $mandate->details;
+
+ // Mollie supports 3 methods here
+ switch ($mandate->method) {
+ case 'creditcard':
+ // If the customer started, but never finished the 'first' payment
+ // card details will be empty, and mandate will be 'pending'.
+ if (empty($details->cardNumber)) {
+ $result['method'] = 'Credit Card';
+ } else {
+ $result['method'] = sprintf(
+ '%s (**** **** **** %s)',
+ $details->cardLabel ?: 'Card', // @phpstan-ignore-line
+ $details->cardNumber
+ );
+ }
+ break;
+
+ case 'directdebit':
+ $result['method'] = sprintf(
+ 'Direct Debit (%s)',
+ $details->customerAccount
+ );
+ break;
+
+ case 'paypal':
+ $result['method'] = sprintf('PayPal (%s)', $details->consumerAccount);
+ break;
+
+
+ default:
+ $result['method'] = 'Unknown method';
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get a provider name
+ *
+ * @return string Provider name
+ */
+ public function name(): string
+ {
+ return 'mollie';
+ }
+
+ /**
+ * Create a new payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - type: oneoff/recurring
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ public function payment(Wallet $wallet, array $payment): ?array
+ {
+ // Register the user in Mollie, if not yet done
+ $customer_id = self::mollieCustomerId($wallet);
+
+ // Note: Required fields: description, amount/currency, amount/value
+
+ $request = [
+ 'amount' => [
+ 'currency' => $payment['currency'],
+ // a number with two decimals is required
+ 'value' => sprintf('%.2f', $payment['amount'] / 100),
+ ],
+ 'customerId' => $customer_id,
+ 'sequenceType' => $payment['type'],
+ 'description' => $payment['description'],
+ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
+ 'locale' => 'en_US',
+ // 'method' => 'creditcard',
+ ];
+
+ if ($payment['type'] == self::TYPE_RECURRING) {
+ // Check if there's a valid mandate
+ $mandate = self::mollieMandate($wallet);
+
+ if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
+ return null;
+ }
+
+ $request['mandateId'] = $mandate->id;
+ } else {
+ // required for non-recurring payments
+ $request['redirectUrl'] = \url('/wallet');
+
+ // TODO: Additional payment parameters for better fraud protection:
+ // billingEmail - for bank transfers, Przelewy24, but not creditcard
+ // billingAddress (it is a structured field not just text)
+ }
+
+ // Create the payment in Mollie
+ $response = mollie()->payments()->create($request);
+
+ // Store the payment reference in database
+ $payment['status'] = $response->status;
+ $payment['id'] = $response->id;
+
+ self::storePayment($payment, $wallet->id);
+
+ return [
+ 'id' => $payment['id'],
+ 'redirectUrl' => $response->getCheckoutUrl(),
+ ];
+ }
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @return int HTTP response code
+ */
+ public function webhook(): int
+ {
+ $payment_id = \request()->input('id');
+
+ if (empty($payment_id)) {
+ return 200;
+ }
+
+ $payment = Payment::find($payment_id);
+
+ if (empty($payment)) {
+ // Mollie recommends to return "200 OK" even if the payment does not exist
+ return 200;
+ }
+
+ // Get the payment details from Mollie
+ $mollie_payment = mollie()->payments()->get($payment_id);
+
+ if (empty($mollie_payment)) {
+ // Mollie recommends to return "200 OK" even if the payment does not exist
+ return 200;
+ }
+
+ if ($mollie_payment->isPaid()) {
+ if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) {
+ // The payment is paid and isn't refunded or charged back.
+ // Update the balance, if it wasn't already
+ if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
+ $payment->wallet->credit($payment->amount);
+ }
+ } elseif ($mollie_payment->hasRefunds()) {
+ // The payment has been (partially) refunded.
+ // The status of the payment is still "paid"
+ // TODO: Update balance
+ } elseif ($mollie_payment->hasChargebacks()) {
+ // The payment has been (partially) charged back.
+ // The status of the payment is still "paid"
+ // TODO: Update balance
+ }
+ } elseif ($mollie_payment->isFailed()) {
+ // Note: I didn't find a way to get any description of the problem with a payment
+ \Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
+ }
+
+ // This is a sanity check, just in case the payment provider api
+ // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid.
+ if ($payment->status != self::STATUS_PAID) {
+ $payment->status = $mollie_payment->status;
+ $payment->save();
+ }
+
+ return 200;
+ }
+
+ /**
+ * Get Mollie customer identifier for specified wallet.
+ * Create one if does not exist yet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string Mollie customer identifier
+ */
+ protected static function mollieCustomerId(Wallet $wallet): string
+ {
+ $customer_id = $wallet->getSetting('mollie_id');
+
+ // Register the user in Mollie
+ if (empty($customer_id)) {
+ $customer = mollie()->customers()->create([
+ 'name' => $wallet->owner->name(),
+ 'email' => $wallet->id . '@private.' . \config('app.domain'),
+ ]);
+
+ $customer_id = $customer->id;
+
+ $wallet->setSetting('mollie_id', $customer->id);
+ }
+
+ return $customer_id;
+ }
+
+ /**
+ * Get the active Mollie auto-payment mandate
+ */
+ protected static function mollieMandate(Wallet $wallet)
+ {
+ $customer_id = $wallet->getSetting('mollie_id');
+ $mandate_id = $wallet->getSetting('mollie_mandate_id');
+
+ // Get the manadate reference we already have
+ if ($customer_id && $mandate_id) {
+ $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
+ if ($mandate) {// && ($mandate->isValid() || $mandate->isPending())) {
+ return $mandate;
+ }
+ }
+
+ // Get all mandates from Mollie and find the active one
+ /*
+ foreach ($customer->mandates() as $mandate) {
+ if ($mandate->isValid() || $mandate->isPending()) {
+ $wallet->setSetting('mollie_mandate_id', $mandate->id);
+ return $mandate;
+ }
+ }
+ */
+ }
+}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
new file mode 100644
--- /dev/null
+++ b/src/app/Providers/Payment/Stripe.php
@@ -0,0 +1,381 @@
+<?php
+
+namespace App\Providers\Payment;
+
+use App\Payment;
+use App\Utils;
+use App\Wallet;
+use App\WalletSetting;
+use Stripe as StripeAPI;
+
+class Stripe extends \App\Providers\PaymentProvider
+{
+ /**
+ * Class constructor.
+ */
+ public function __construct()
+ {
+ StripeAPI\Stripe::setApiKey(\config('services.stripe.key'));
+ }
+
+ /**
+ * Get a link to the customer in the provider's control panel
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string|null The string representing <a> tag
+ */
+ public function customerLink(Wallet $wallet): ?string
+ {
+ $customer_id = self::stripeCustomerId($wallet);
+
+ $location = 'https://dashboard.stripe.com';
+
+ $key = \config('services.stripe.key');
+
+ if (strpos($key, 'sk_test_') === 0) {
+ $location .= '/test';
+ }
+
+ return sprintf(
+ '<a href="%s/customers/%s" target="_blank">%s</a>',
+ $location,
+ $customer_id,
+ $customer_id
+ );
+ }
+
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment/session data:
+ * - id: Session identifier
+ */
+ public function createMandate(Wallet $wallet, array $payment): ?array
+ {
+ // Register the user in Stripe, if not yet done
+ $customer_id = self::stripeCustomerId($wallet);
+
+ $request = [
+ 'customer' => $customer_id,
+ 'cancel_url' => \url('/wallet'), // required
+ 'success_url' => \url('/wallet'), // required
+ 'payment_method_types' => ['card'], // required
+ 'locale' => 'en',
+ 'mode' => 'setup',
+ ];
+
+ $session = StripeAPI\Checkout\Session::create($request);
+
+ return [
+ 'id' => $session->id,
+ ];
+ }
+
+ /**
+ * Revoke the auto-payment mandate.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return bool True on success, False on failure
+ */
+ public function deleteMandate(Wallet $wallet): bool
+ {
+ // Get the Mandate info
+ $mandate = self::stripeMandate($wallet);
+
+ if ($mandate) {
+ // Remove the reference
+ $wallet->setSetting('stripe_mandate_id', null);
+
+ // Detach the payment method on Stripe
+ $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
+ $pm->detach();
+ }
+
+ return true;
+ }
+
+ /**
+ * Get a auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ public function getMandate(Wallet $wallet): ?array
+ {
+ // Get the Mandate info
+ $mandate = self::stripeMandate($wallet);
+
+ if (empty($mandate)) {
+ return null;
+ }
+
+ $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
+
+ $result = [
+ 'id' => $mandate->id,
+ 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
+ 'isValid' => $mandate->status == 'succeeded',
+ ];
+
+ switch ($pm->type) {
+ case 'card':
+ // TODO: card number
+ $result['method'] = \sprintf(
+ '%s (**** **** **** %s)',
+ // @phpstan-ignore-next-line
+ \ucfirst($pm->card->brand) ?: 'Card',
+ // @phpstan-ignore-next-line
+ $pm->card->last4
+ );
+
+ break;
+
+ default:
+ $result['method'] = 'Unknown method';
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get a provider name
+ *
+ * @return string Provider name
+ */
+ public function name(): string
+ {
+ return 'stripe';
+ }
+
+ /**
+ * Create a new payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - type: first/oneoff/recurring
+ * - description: Operation desc.
+ *
+ * @return array Provider payment/session data:
+ * - id: Session identifier
+ */
+ public function payment(Wallet $wallet, array $payment): ?array
+ {
+ if ($payment['type'] == self::TYPE_RECURRING) {
+ return $this->paymentRecurring($wallet, $payment);
+ }
+
+ // Register the user in Stripe, if not yet done
+ $customer_id = self::stripeCustomerId($wallet);
+
+ $request = [
+ 'customer' => $customer_id,
+ 'cancel_url' => \url('/wallet'), // required
+ 'success_url' => \url('/wallet'), // required
+ 'payment_method_types' => ['card'], // required
+ 'locale' => 'en',
+ 'line_items' => [
+ [
+ 'name' => $payment['description'],
+ 'amount' => $payment['amount'],
+ 'currency' => \strtolower($payment['currency']),
+ 'quantity' => 1,
+ ]
+ ]
+ ];
+
+ $session = StripeAPI\Checkout\Session::create($request);
+
+ // Store the payment reference in database
+ $payment['status'] = self::STATUS_OPEN;
+ $payment['id'] = $session->payment_intent;
+
+ self::storePayment($payment, $wallet->id);
+
+ return [
+ 'id' => $session->id,
+ ];
+ }
+
+ /**
+ * Create a new automatic payment operation.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data (see self::payment())
+ *
+ * @return array Provider payment/session data:
+ * - id: Session identifier
+ */
+ protected function paymentRecurring(Wallet $wallet, array $payment): ?array
+ {
+ // Check if there's a valid mandate
+ $mandate = self::stripeMandate($wallet);
+
+ if (empty($mandate)) {
+ return null;
+ }
+
+ $request = [
+ 'amount' => $payment['amount'],
+ 'currency' => \strtolower($payment['currency']),
+ 'description' => $payment['description'],
+ 'locale' => 'en',
+ 'off_session' => true,
+ 'receipt_email' => $wallet->owner->email,
+ 'customer' => $mandate->customer,
+ 'payment_method' => $mandate->payment_method,
+ ];
+
+ $intent = StripeAPI\PaymentIntent::create($request);
+
+ // Store the payment reference in database
+ $payment['status'] = self::STATUS_OPEN;
+ $payment['id'] = $intent->id;
+
+ self::storePayment($payment, $wallet->id);
+
+ return [
+ 'id' => $payment['id'],
+ ];
+ }
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @return int HTTP response code
+ */
+ public function webhook(): int
+ {
+ $payload = file_get_contents('php://input');
+ $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'];
+
+ // Parse and validate the input
+ try {
+ $event = StripeAPI\Webhook::constructEvent(
+ $payload,
+ $sig_header,
+ \config('services.stripe.webhook_secret')
+ );
+ } catch (\UnexpectedValueException $e) {
+ // Invalid payload
+ return 400;
+ }
+
+ switch ($event->type) {
+ case StripeAPI\Event::PAYMENT_INTENT_CANCELED:
+ case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED:
+ case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED:
+ $intent = $event->data->object; // @phpstan-ignore-line
+ $payment = Payment::find($intent->id);
+
+ switch ($intent->status) {
+ case StripeAPI\PaymentIntent::STATUS_CANCELED:
+ $status = self::STATUS_CANCELED;
+ break;
+ case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
+ $status = self::STATUS_PAID;
+ break;
+ default:
+ $status = self::STATUS_PENDING;
+ }
+
+ if ($status == self::STATUS_PAID) {
+ // Update the balance, if it wasn't already
+ if ($payment->status != self::STATUS_PAID) {
+ $payment->wallet->credit($payment->amount);
+ }
+ } elseif (!empty($intent->last_payment_error)) {
+ // See https://stripe.com/docs/error-codes for more info
+ \Log::info(sprintf(
+ 'Stripe payment failed (%s): %s',
+ $payment->id,
+ json_encode($intent->last_payment_error)
+ ));
+ }
+
+ if ($payment->status != self::STATUS_PAID) {
+ $payment->status = $status;
+ $payment->save();
+ }
+
+ break;
+
+ case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
+ $intent = $event->data->object; // @phpstan-ignore-line
+
+ // Find the wallet
+ // TODO: This query is potentially slow, we should find another way
+ // Maybe use payment/transactions table to store the reference
+ $setting = WalletSetting::where('key', 'stripe_id')
+ ->where('value', $intent->customer)->first();
+
+ if ($setting) {
+ $setting->wallet->setSetting('stripe_mandate_id', $intent->id);
+ }
+
+ break;
+ }
+
+ return 200;
+ }
+
+ /**
+ * Get Stripe customer identifier for specified wallet.
+ * Create one if does not exist yet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string Stripe customer identifier
+ */
+ protected static function stripeCustomerId(Wallet $wallet): string
+ {
+ $customer_id = $wallet->getSetting('stripe_id');
+
+ // Register the user in Stripe
+ if (empty($customer_id)) {
+ $customer = StripeAPI\Customer::create([
+ 'name' => $wallet->owner->name(),
+ // Stripe will display the email on Checkout page, editable,
+ // and use it to send the receipt (?), use the user email here
+ // 'email' => $wallet->id . '@private.' . \config('app.domain'),
+ 'email' => $wallet->owner->email,
+ ]);
+
+ $customer_id = $customer->id;
+
+ $wallet->setSetting('stripe_id', $customer->id);
+ }
+
+ return $customer_id;
+ }
+
+ /**
+ * Get the active Stripe auto-payment mandate (Setup Intent)
+ */
+ protected static function stripeMandate(Wallet $wallet)
+ {
+ // Note: Stripe also has 'Mandate' objects, but we do not use these
+
+ if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) {
+ $mandate = StripeAPI\SetupIntent::retrieve($mandate_id);
+ // @phpstan-ignore-next-line
+ if ($mandate && $mandate->status != 'canceled') {
+ return $mandate;
+ }
+ }
+ }
+}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
new file mode 100644
--- /dev/null
+++ b/src/app/Providers/PaymentProvider.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace App\Providers;
+
+use App\Payment;
+use App\Wallet;
+
+abstract class PaymentProvider
+{
+ public const STATUS_OPEN = 'open';
+ public const STATUS_CANCELED = 'canceled';
+ public const STATUS_PENDING = 'pending';
+ public const STATUS_AUTHORIZED = 'authorized';
+ public const STATUS_EXPIRED = 'expired';
+ public const STATUS_FAILED = 'failed';
+ public const STATUS_PAID = 'paid';
+
+ public const TYPE_ONEOFF = 'oneoff';
+ public const TYPE_RECURRING = 'recurring';
+
+ /** const int Minimum amount of money in a single payment (in cents) */
+ public const MIN_AMOUNT = 1000;
+
+ /**
+ * Factory method
+ *
+ * @param \App\Wallet|string|null $provider_or_wallet
+ */
+ public static function factory($provider_or_wallet = null)
+ {
+ if ($provider_or_wallet instanceof Wallet) {
+ if ($provider_or_wallet->getSetting('stripe_id')) {
+ $provider = 'stripe';
+ } elseif ($provider_or_wallet->getSetting('mollie_id')) {
+ $provider = 'mollie';
+ }
+ } else {
+ $provider = $provider_or_wallet;
+ }
+
+ if (empty($provider)) {
+ $provider = \config('services.payment_provider') ?: 'mollie';
+ }
+
+ switch (\strtolower($provider)) {
+ case 'stripe':
+ return new \App\Providers\Payment\Stripe();
+
+ case 'mollie':
+ return new \App\Providers\Payment\Mollie();
+
+ default:
+ throw new \Exception("Invalid payment provider: {$provider}");
+ }
+ }
+
+ /**
+ * Create a new auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - description: Operation desc.
+ *
+ * @return array Provider payment data:
+ * - id: Operation identifier
+ * - redirectUrl: the location to redirect to
+ */
+ abstract public function createMandate(Wallet $wallet, array $payment): ?array;
+
+ /**
+ * Revoke the auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return bool True on success, False on failure
+ */
+ abstract public function deleteMandate(Wallet $wallet): bool;
+
+ /**
+ * Get a auto-payment mandate for a wallet.
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return array|null Mandate information:
+ * - id: Mandate identifier
+ * - method: user-friendly payment method desc.
+ * - isPending: the process didn't complete yet
+ * - isValid: the mandate is valid
+ */
+ abstract public function getMandate(Wallet $wallet): ?array;
+
+ /**
+ * Get a link to the customer in the provider's control panel
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string|null The string representing <a> tag
+ */
+ abstract public function customerLink(Wallet $wallet): ?string;
+
+ /**
+ * Get a provider name
+ *
+ * @return string Provider name
+ */
+ abstract public function name(): string;
+
+ /**
+ * Create a new payment.
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $payment Payment data:
+ * - amount: Value in cents
+ * - currency: The operation currency
+ * - type: first/oneoff/recurring
+ * - description: Operation description
+ *
+ * @return array Provider payment/session data:
+ * - id: Operation identifier
+ * - redirectUrl
+ */
+ abstract public function payment(Wallet $wallet, array $payment): ?array;
+
+ /**
+ * Update payment status (and balance).
+ *
+ * @return int HTTP response code
+ */
+ abstract public function webhook(): int;
+
+ /**
+ * Create a payment record in DB
+ *
+ * @param array $payment Payment information
+ * @param string $wallet_id Wallet ID
+ */
+ protected static function storePayment(array $payment, $wallet_id): void
+ {
+ $db_payment = new Payment();
+ $db_payment->id = $payment['id'];
+ $db_payment->description = $payment['description'];
+ $db_payment->status = $payment['status'];
+ $db_payment->amount = $payment['amount'];
+ $db_payment->wallet_id = $wallet_id;
+ $db_payment->save();
+ }
+}
diff --git a/src/app/Traits/UserSettingsTrait.php b/src/app/Traits/SettingsTrait.php
rename from src/app/Traits/UserSettingsTrait.php
rename to src/app/Traits/SettingsTrait.php
--- a/src/app/Traits/UserSettingsTrait.php
+++ b/src/app/Traits/SettingsTrait.php
@@ -2,10 +2,9 @@
namespace App\Traits;
-use App\UserSetting;
use Illuminate\Support\Facades\Cache;
-trait UserSettingsTrait
+trait SettingsTrait
{
/**
* Obtain the value for a setting.
@@ -17,9 +16,9 @@
* $locale = $user->getSetting('locale');
* ```
*
- * @param string $key Lookup key
+ * @param string $key Setting name
*
- * @return string|null
+ * @return string|null Setting value
*/
public function getSetting(string $key)
{
@@ -35,6 +34,25 @@
}
/**
+ * Remove a setting.
+ *
+ * Example Usage:
+ *
+ * ```php
+ * $user = User::firstOrCreate(['email' => 'some@other.erg']);
+ * $user->removeSetting('locale');
+ * ```
+ *
+ * @param string $key Setting name
+ *
+ * @return void
+ */
+ public function removeSetting(string $key): void
+ {
+ $this->setSetting($key, null);
+ }
+
+ /**
* Create or update a setting.
*
* Example Usage:
@@ -49,7 +67,7 @@
*
* @return void
*/
- public function setSetting(string $key, $value)
+ public function setSetting(string $key, $value): void
{
$this->storeSetting($key, $value);
$this->setCache();
@@ -69,7 +87,7 @@
*
* @return void
*/
- public function setSettings(array $data = [])
+ public function setSettings(array $data = []): void
{
foreach ($data as $key => $value) {
$this->storeSetting($key, $value);
@@ -81,12 +99,13 @@
private function storeSetting(string $key, $value): void
{
if ($value === null || $value === '') {
- if ($setting = UserSetting::where(['user_id' => $this->id, 'key' => $key])->first()) {
+ // Note: We're selecting the record first, so observers can act
+ if ($setting = $this->settings()->where('key', $key)->first()) {
$setting->delete();
}
} else {
- UserSetting::updateOrCreate(
- ['user_id' => $this->id, 'key' => $key],
+ $this->settings()->updateOrCreate(
+ ['key' => $key],
['value' => $value]
);
}
@@ -94,8 +113,10 @@
private function getCache()
{
- if (Cache::has('user_settings_' . $this->id)) {
- return Cache::get('user_settings_' . $this->id);
+ $model = \strtolower(get_class($this));
+
+ if (Cache::has("{$model}_settings_{$this->id}")) {
+ return Cache::get("{$model}_settings_{$this->id}");
}
return $this->setCache();
@@ -103,8 +124,10 @@
private function setCache()
{
- if (Cache::has('user_settings_' . $this->id)) {
- Cache::forget('user_settings_' . $this->id);
+ $model = \strtolower(get_class($this));
+
+ if (Cache::has("{$model}_settings_{$this->id}")) {
+ Cache::forget("{$model}_settings_{$this->id}");
}
$cached = [];
@@ -114,7 +137,7 @@
}
}
- Cache::forever('user_settings_' . $this->id, $cached);
+ Cache::forever("{$model}_settings_{$this->id}", $cached);
return $this->getCache();
}
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -5,7 +5,7 @@
use App\Entitlement;
use App\UserAlias;
use App\Traits\UserAliasesTrait;
-use App\Traits\UserSettingsTrait;
+use App\Traits\SettingsTrait;
use App\Wallet;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -26,7 +26,7 @@
use Notifiable;
use NullableFields;
use UserAliasesTrait;
- use UserSettingsTrait;
+ use SettingsTrait;
use SoftDeletes;
// a new user, default on creation
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -98,10 +98,34 @@
$isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0;
$env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js';
+ $env['paymentProvider'] = \config('services.payment_provider');
+ $env['stripePK'] = \config('services.stripe.public_key');
+
return $env;
}
/**
+ * Create self URL
+ *
+ * @param string $route Route/Path
+ *
+ * @return string Full URL
+ */
+ public static function serviceUrl(string $route): string
+ {
+ $url = \url($route);
+
+ $app_url = trim(\config('app.url'), '/');
+ $pub_url = trim(\config('app.public_url'), '/');
+
+ if ($pub_url != $app_url) {
+ $url = str_replace($app_url, $pub_url, $url);
+ }
+
+ return $url;
+ }
+
+ /**
* Email address (login or alias) validation
*
* @param string $email Email address
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -3,6 +3,7 @@
namespace App;
use App\User;
+use App\Traits\SettingsTrait;
use Carbon\Carbon;
use Iatstuti\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
@@ -17,6 +18,7 @@
class Wallet extends Model
{
use NullableFields;
+ use SettingsTrait;
public $incrementing = false;
protected $keyType = 'string';
@@ -208,4 +210,14 @@
{
return $this->hasMany('App\Payment');
}
+
+ /**
+ * Any (additional) properties of this wallet.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function settings()
+ {
+ return $this->hasMany('App\WalletSetting');
+ }
}
diff --git a/src/app/WalletSetting.php b/src/app/WalletSetting.php
new file mode 100644
--- /dev/null
+++ b/src/app/WalletSetting.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * A collection of settings for a Wallet.
+ *
+ * @property int $id
+ * @property string $wallet_id
+ * @property string $key
+ * @property string $value
+ */
+class WalletSetting extends Model
+{
+ protected $fillable = [
+ 'wallet_id', 'key', 'value'
+ ];
+
+ /**
+ * The wallet to which this setting belongs.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function wallet()
+ {
+ return $this->belongsTo(
+ '\App\Wallet',
+ 'wallet_id', /* local */
+ 'id' /* remote */
+ );
+ }
+}
diff --git a/src/composer.json b/src/composer.json
--- a/src/composer.json
+++ b/src/composer.json
@@ -27,6 +27,7 @@
"silviolleite/laravelpwa": "^1.0",
"spatie/laravel-translatable": "^4.2",
"spomky-labs/otphp": "~4.0.0",
+ "stripe/stripe-php": "^7.29",
"swooletw/laravel-swoole": "^2.6",
"torann/currency": "^1.0",
"torann/geoip": "^1.0",
diff --git a/src/config/services.php b/src/config/services.php
--- a/src/config/services.php
+++ b/src/config/services.php
@@ -34,4 +34,16 @@
'secret' => env('SPARKPOST_SECRET'),
],
+ 'payment_provider' => env('PAYMENT_PROVIDER', 'mollie'),
+
+ 'mollie' => [
+ 'key' => env('MOLLIE_KEY'),
+ ],
+
+ 'stripe' => [
+ 'key' => env('STRIPE_KEY'),
+ 'public_key' => env('STRIPE_PUBLIC_KEY'),
+ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
+ ],
+
];
diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php
--- a/src/database/migrations/2020_03_16_100000_create_payments.php
+++ b/src/database/migrations/2020_03_16_100000_create_payments.php
@@ -16,7 +16,7 @@
Schema::create(
'payments',
function (Blueprint $table) {
- $table->string('id', 16)->primary();
+ $table->string('id', 32)->primary();
$table->string('wallet_id', 36);
$table->string('status', 16);
$table->integer('amount');
diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php
copy from src/database/migrations/2020_03_16_100000_create_payments.php
copy to src/database/migrations/2020_04_21_100000_create_wallet_settings.php
--- a/src/database/migrations/2020_03_16_100000_create_payments.php
+++ b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php
@@ -4,7 +4,7 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
-class CreatePayments extends Migration
+class CreateWalletSettings extends Migration
{
/**
* Run the migrations.
@@ -14,16 +14,16 @@
public function up()
{
Schema::create(
- 'payments',
+ 'wallet_settings',
function (Blueprint $table) {
- $table->string('id', 16)->primary();
- $table->string('wallet_id', 36);
- $table->string('status', 16);
- $table->integer('amount');
- $table->text('description');
+ $table->bigIncrements('id');
+ $table->string('wallet_id');
+ $table->string('key');
+ $table->string('value');
$table->timestamps();
$table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade');
+ $table->unique(['wallet_id', 'key']);
}
);
}
@@ -35,6 +35,6 @@
*/
public function down()
{
- Schema::dropIfExists('payments');
+ Schema::dropIfExists('wallet_settings');
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -10,6 +10,8 @@
import MenuComponent from '../vue/Widgets/Menu'
import store from './store'
+const loader = '<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'
+
const app = new Vue({
el: '#app',
components: {
@@ -67,13 +69,20 @@
delete axios.defaults.headers.common.Authorization
this.$router.push({ name: 'login' })
},
- // Display "loading" overlay (to be used by route components)
+ // Display "loading" overlay inside of the specified element
+ addLoader(elem) {
+ $(elem).css({position: 'relative'}).append($(loader).addClass('small'))
+ },
+ // Remove loader element added in addLoader()
+ removeLoader(elem) {
+ $(elem).find('.app-loader').remove()
+ },
startLoading() {
this.isLoading = true
// Lock the UI with the 'loading...' element
let loading = $('#app > .app-loader').show()
if (!loading.length) {
- $('#app').append($('<div class="app-loader"><div class="spinner-border" role="status"><span class="sr-only">Loading</span></div></div>'))
+ $('#app').append($(loader))
}
},
// Hide "loading" overlay
@@ -193,6 +202,21 @@
}
})
+// Add a axios request interceptor
+window.axios.interceptors.request.use(
+ config => {
+ // This is the only way I found to change configuration options
+ // on a running application. We need this for browser testing.
+ config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
+
+ return config
+ },
+ error => {
+ // Do something with request error
+ return Promise.reject(error)
+ }
+)
+
// Add a axios response interceptor for general/validation error handler
window.axios.interceptors.response.use(
response => {
@@ -212,7 +236,7 @@
form = $(form)
$.each(error.response.data.errors || {}, (idx, msg) => {
- const input_name = (form.data('validation-prefix') || '') + idx
+ const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js
--- a/src/resources/js/fontawesome.js
+++ b/src/resources/js/fontawesome.js
@@ -4,6 +4,7 @@
//import { } from '@fortawesome/free-brands-svg-icons'
import {
faCheckSquare,
+ faCreditCard,
faSquare,
} from '@fortawesome/free-regular-svg-icons'
@@ -31,6 +32,7 @@
faCheck,
faCheckCircle,
faCheckSquare,
+ faCreditCard,
faExclamationCircle,
faGlobe,
faInfoCircle,
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -10,6 +10,9 @@
| The following language lines are used in the application.
*/
+ 'mandate-delete-success' => 'The auto-payment has been removed.',
+ 'mandate-update-success' => 'The auto-payment has been updated.',
+
'planbutton' => 'Choose :plan',
'process-user-new' => 'Registering a user...',
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -117,7 +117,6 @@
'url' => 'The :attribute format is invalid.',
'uuid' => 'The :attribute must be a valid UUID.',
-
'2fareq' => 'Second factor code is required.',
'2fainvalid' => 'Second factor code is invalid.',
'emailinvalid' => 'The specified email address is invalid.',
@@ -133,6 +132,7 @@
'noextemail' => 'This user has no external email address.',
'entryinvalid' => 'The specified :attribute is invalid.',
'entryexists' => 'The specified :attribute is not available.',
+ 'minamount' => 'Minimum amount for a single payment is :amount.',
/*
|--------------------------------------------------------------------------
diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss
--- a/src/resources/sass/app.scss
+++ b/src/resources/sass/app.scss
@@ -84,6 +84,12 @@
border-width: 15px;
color: #b2aa99;
}
+
+ &.small .spinner-border {
+ width: 25px;
+ height: 25px;
+ border-width: 3px;
+ }
}
pre {
@@ -215,6 +221,27 @@
}
}
+.form-separator {
+ position: relative;
+ margin: 1em 0;
+ display: flex;
+ justify-content: center;
+
+ hr {
+ border-color: #999;
+ margin: 0;
+ position: absolute;
+ top: .75em;
+ width: 100%;
+ }
+
+ span {
+ background: #fff;
+ padding: 0 1em;
+ z-index: 1;
+ }
+}
+
// Bootstrap style fix
.btn-link {
border: 0;
diff --git a/src/resources/sass/forms.scss b/src/resources/sass/forms.scss
--- a/src/resources/sass/forms.scss
+++ b/src/resources/sass/forms.scss
@@ -32,3 +32,13 @@
margin-right: 0.5em;
}
}
+
+.form-control-plaintext .btn-sm {
+ margin-top: -0.25rem;
+}
+
+form.read-only {
+ .row {
+ margin-bottom: 0;
+ }
+}
diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue
--- a/src/resources/vue/Admin/User.vue
+++ b/src/resources/vue/Admin/User.vue
@@ -4,8 +4,8 @@
<div class="card-body">
<div class="card-title">{{ user.email }}</div>
<div class="card-text">
- <form>
- <div v-if="user.wallet.user_id != user.id" class="form-group row mb-0">
+ <form class="read-only">
+ <div v-if="user.wallet.user_id != user.id" class="form-group row">
<label for="manager" class="col-sm-4 col-form-label">Managed by</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="manager">
@@ -13,7 +13,7 @@
</span>
</div>
</div>
- <div class="form-group row mb-0">
+ <div class="form-group row">
<label for="userid" class="col-sm-4 col-form-label">ID <span class="text-muted">(Created at)</span></label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="userid">
@@ -21,7 +21,7 @@
</span>
</div>
</div>
- <div class="form-group row mb-0">
+ <div class="form-group row">
<label for="status" class="col-sm-4 col-form-label">Status</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="status">
@@ -29,31 +29,31 @@
</span>
</div>
</div>
- <div class="form-group row mb-0" v-if="user.first_name">
+ <div class="form-group row" v-if="user.first_name">
<label for="first_name" class="col-sm-4 col-form-label">First name</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="first_name">{{ user.first_name }}</span>
</div>
</div>
- <div class="form-group row mb-0" v-if="user.last_name">
+ <div class="form-group row" v-if="user.last_name">
<label for="last_name" class="col-sm-4 col-form-label">Last name</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="last_name">{{ user.last_name }}</span>
</div>
</div>
- <div class="form-group row mb-0" v-if="user.organization">
+ <div class="form-group row" v-if="user.organization">
<label for="organization" class="col-sm-4 col-form-label">Organization</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="organization">{{ user.organization }}</span>
</div>
</div>
- <div class="form-group row mb-0" v-if="user.phone">
+ <div class="form-group row" v-if="user.phone">
<label for="phone" class="col-sm-4 col-form-label">Phone</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="phone">{{ user.phone }}</span>
</div>
</div>
- <div class="form-group row mb-0">
+ <div class="form-group row">
<label for="external_email" class="col-sm-4 col-form-label">External email</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="external_email">
@@ -62,13 +62,13 @@
</span>
</div>
</div>
- <div class="form-group row mb-0" v-if="user.billing_address">
+ <div class="form-group row" v-if="user.billing_address">
<label for="billing_address" class="col-sm-4 col-form-label">Address</label>
<div class="col-sm-8">
<span class="form-control-plaintext" style="white-space:pre" id="billing_address">{{ user.billing_address }}</span>
</div>
</div>
- <div class="form-group row mb-0">
+ <div class="form-group row">
<label for="country" class="col-sm-4 col-form-label">Country</label>
<div class="col-sm-8">
<span class="form-control-plaintext" id="country">{{ user.country }}</span>
@@ -108,18 +108,34 @@
<div class="tab-content">
<div class="tab-pane show active" id="user-finances" role="tabpanel" aria-labelledby="tab-finances">
<div class="card-body">
- <div class="card-title">Account balance <span :class="balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(balance) }}</strong></span></div>
+ <div class="card-title">Account balance <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(wallet.balance) }}</strong></span></div>
<div class="card-text">
- <form>
- <div class="form-group row mb-0">
- <label class="col-sm-2 col-form-label">Discount:</label>
- <div class="col-sm-10">
+ <form class="read-only">
+ <div class="form-group row">
+ <label class="col-sm-4 col-form-label">Discount</label>
+ <div class="col-sm-8">
<span class="form-control-plaintext" id="discount">
- <span>{{ wallet_discount ? (wallet_discount + '% - ' + wallet_discount_description) : 'none' }}</span>
+ <span>{{ wallet.discount ? (wallet.discount + '% - ' + wallet.discount_description) : 'none' }}</span>
<button type="button" class="btn btn-secondary btn-sm" @click="discountEdit">Edit</button>
</span>
</div>
</div>
+ <div class="form-group row" v-if="wallet.mandate && wallet.mandate.id">
+ <label class="col-sm-4 col-form-label">Auto-payment</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" id="autopayment">
+ Fill up by <b>{{ wallet.mandate.amount }} CHF</b>
+ when under <b>{{ wallet.mandate.balance }} CHF</b>
+ using {{ wallet.mandate.method }}.</span>
+ </span>
+ </div>
+ </div>
+ <div class="form-group row" v-if="wallet.providerLink">
+ <label class="col-sm-4 col-form-label">{{ capitalize(wallet.provider) }} ID</label>
+ <div class="col-sm-8">
+ <span class="form-control-plaintext" v-html="wallet.providerLink"></span>
+ </div>
+ </div>
</form>
</div>
</div>
@@ -241,7 +257,7 @@
</div>
<div class="modal-body">
<p class="form-group">
- <select v-model="wallet_discount_id" class="custom-select">
+ <select v-model="wallet.discount_id" class="custom-select">
<option value="">- none -</option>
<option v-for="item in discounts" :value="item.id" :key="item.id">{{ item.label }}</option>
</select>
@@ -294,14 +310,11 @@
},
data() {
return {
- balance: 0,
discount: 0,
discount_description: '',
discounts: [],
external_email: '',
- wallet_discount: 0,
- wallet_discount_description: '',
- wallet_discount_id: '',
+ wallet: {},
domains: [],
skus: [],
users: [],
@@ -323,8 +336,9 @@
this.user = response.data
- let keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
- let country = this.user.settings.country
+ const financesTab = '#user-finances'
+ const keys = ['first_name', 'last_name', 'external_email', 'billing_address', 'phone', 'organization']
+ const country = this.user.settings.country
if (country) {
this.user.country = window.config.countries[country][1]
@@ -336,13 +350,16 @@
this.discount_description = this.user.wallet.discount_description
// TODO: currencies, multi-wallets, accounts
- this.user.wallets.forEach(wallet => {
- this.balance += wallet.balance
- })
-
- this.wallet_discount = this.user.wallets[0].discount
- this.wallet_discount_id = this.user.wallets[0].discount_id || ''
- this.wallet_discount_description = this.user.wallets[0].discount_description
+ // Get more info about the wallet (e.g. payment provider related)
+ this.$root.addLoader(financesTab)
+ axios.get('/api/v4/wallets/' + this.user.wallets[0].id)
+ .then(response => {
+ this.$root.removeLoader(financesTab)
+ this.wallet = response.data
+ })
+ .catch(error => {
+ this.$root.removeLoader(financesTab)
+ })
// Create subscriptions list
axios.get('/api/v4/skus')
@@ -392,10 +409,15 @@
})
},
methods: {
+ capitalize(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1)
+ },
discountEdit() {
$('#discount-dialog')
.on('shown.bs.modal', e => {
$(e.target).find('select').focus()
+ // Note: Vue v-model is strict, convert null to a string
+ this.wallet.discount_id = this.wallet_discount_id || ''
})
.modal()
@@ -420,18 +442,16 @@
submitDiscount() {
$('#discount-dialog').modal('hide')
- axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet_discount_id })
+ axios.put('/api/v4/wallets/' + this.user.wallets[0].id, { discount: this.wallet.discount_id })
.then(response => {
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
- this.wallet_discount = response.data.discount
- this.wallet_discount_id = response.data.discount_id || ''
- this.wallet_discount_description = response.data.discount_description
+ this.wallet = Object.assign({}, this.wallet, response.data)
// Update prices in Subscriptions tab
if (this.user.wallet.id == response.data.id) {
- this.discount = this.wallet_discount
- this.discount_description = this.wallet_discount_description
+ this.discount = this.wallet.discount
+ this.discount_description = this.wallet.discount_description
this.skus.forEach(sku => {
sku.price = this.$root.priceLabel(sku.cost, sku.units, this.discount)
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -7,7 +7,99 @@
<p>Current account balance is
<span :class="balance < 0 ? 'text-danger' : 'text-success'"><strong>{{ $root.price(balance) }}</strong></span>
</p>
- <button type="button" class="btn btn-primary" @click="payment()">Add 10 bucks to my wallet</button>
+ <button type="button" class="btn btn-primary" @click="paymentDialog()">Add credit</button>
+ </div>
+ </div>
+ </div>
+
+ <div id="payment-dialog" class="modal" tabindex="-1" role="dialog">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">{{ paymentDialogTitle }}</h5>
+ <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+ <span aria-hidden="true">×</span>
+ </button>
+ </div>
+ <div class="modal-body">
+ <div id="payment" v-if="paymentForm == 'init'">
+ <p>Choose the amount by which you want to top up your wallet.</p>
+ <form id="payment-form" @submit.prevent="payment">
+ <div class="form-group">
+ <div class="input-group">
+ <input type="text" class="form-control" id="amount" v-model="amount" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="w-100 text-center">
+ <button type="submit" class="btn btn-primary">
+ <svg-icon :icon="['far', 'credit-card']"></svg-icon> Continue
+ </button>
+ </div>
+ </form>
+ <div class="form-separator"><hr><span>or</span></div>
+ <div id="mandate-form" v-if="!mandate.id">
+ <p>Add auto-payment, so you never run out.</p>
+ <div class="w-100 text-center">
+ <button type="button" class="btn btn-primary" @click="autoPaymentForm">Set up auto-payment</button>
+ </div>
+ </div>
+ <div id="mandate-info" v-if="mandate.id">
+ <p>Auto-payment is set to fill up your account by <b>{{ mandate.amount }} CHF</b>
+ every time your account balance gets under <b>{{ mandate.balance }} CHF</b>.
+ You will be charged via {{ mandate.method }}.
+ </p>
+ <p>You can cancel or change the auto-payment at any time.</p>
+ <div class="form-group d-flex justify-content-around">
+ <button type="button" class="btn btn-danger" @click="autoPaymentDelete">Cancel auto-payment</button>
+ <button type="button" class="btn btn-primary" @click="autoPaymentChange">Change auto-payment</button>
+ </div>
+ </div>
+ </div>
+ <div id="auto-payment" v-if="paymentForm == 'auto'">
+ <form data-validation-prefix="mandate_">
+ <p>Here is how it works: Every time your account runs low,
+ we will charge your preferred payment method for an amount you choose.
+ You can cancel or change the auto-payment option at any time.
+ </p>
+ <div class="form-group row">
+ <label for="mandate_amount" class="col-sm-6 col-form-label">Fill up by</label>
+ <div class="input-group col-sm-6">
+ <input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label for="mandate_balance" class="col-sm-6 col-form-label">when account balance is below</label>
+ <div class="col-sm-6">
+ <div class="input-group">
+ <input type="text" class="form-control" id="mandate_balance" v-model="mandate.balance" required>
+ <span class="input-group-append">
+ <span class="input-group-text">{{ wallet_currency }}</span>
+ </span>
+ </div>
+ </div>
+ </div>
+ <p v-if="!mandate.id">
+ Next, you will be redirected to the checkout page, where you can provide
+ your credit card details.
+ </p>
+ </form>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-secondary modal-cancel" data-dismiss="modal">Cancel</button>
+ <button type="button" class="btn btn-primary modal-action" v-if="paymentForm == 'auto' && mandate.id" @click="autoPayment">
+ <svg-icon icon="check"></svg-icon> Submit
+ </button>
+ <button type="button" class="btn btn-primary modal-action" v-if="paymentForm == 'auto' && !mandate.id" @click="autoPayment">
+ <svg-icon :icon="['far', 'credit-card']"></svg-icon> Continue
+ </button>
+ </div>
</div>
</div>
</div>
@@ -18,7 +110,14 @@
export default {
data() {
return {
+ amount: '',
balance: 0,
+ mandate: { amount: 10, balance: 0 },
+ paymentDialogTitle: null,
+ paymentForm: 'init',
+ provider: window.config.paymentProvider,
+ stripe: null,
+ wallet_currency: 'CHF'
}
},
mounted() {
@@ -26,16 +125,123 @@
// TODO: currencies, multi-wallets, accounts
this.$store.state.authInfo.wallets.forEach(wallet => {
this.balance += wallet.balance
+ this.provider = wallet.provider
})
+
+ if (this.provider == 'stripe') {
+ this.stripeInit()
+ }
},
methods: {
+ paymentDialog() {
+ const dialog = $('#payment-dialog')
+ const mandate_form = $('#mandate-form')
+
+ this.$root.removeLoader(mandate_form)
+
+ if (!this.mandate.id) {
+ this.$root.addLoader(mandate_form)
+ axios.get('/api/v4/payments/mandate')
+ .then(response => {
+ this.$root.removeLoader(mandate_form)
+ this.mandate = response.data
+ })
+ .catch(error => {
+ this.$root.removeLoader(mandate_form)
+ })
+ }
+
+ this.paymentForm = 'init'
+ this.paymentDialogTitle = 'Top up your wallet'
+
+ this.dialog = dialog.on('shown.bs.modal', () => {
+ dialog.find('#amount').focus()
+ }).modal()
+ },
payment() {
- axios.post('/api/v4/payments', {amount: 1000})
+ this.$root.clearFormValidation($('#payment-form'))
+
+ axios.post('/api/v4/payments', {amount: this.amount})
+ .then(response => {
+ if (response.data.redirectUrl) {
+ location.href = response.data.redirectUrl
+ } else {
+ this.stripeCheckout(response.data)
+ }
+ })
+ },
+ autoPayment() {
+ const method = this.mandate.id ? 'put' : 'post'
+ const post = {
+ amount: this.mandate.amount,
+ balance: this.mandate.balance
+ }
+
+ this.$root.clearFormValidation($('#auto-payment form'))
+
+ axios[method]('/api/v4/payments/mandate', post)
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
+ } else if (response.data.id) {
+ this.stripeCheckout(response.data)
+ } else {
+ this.dialog.modal('hide')
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ }
}
})
+ },
+ autoPaymentChange(event) {
+ this.autoPaymentForm(event, 'Update auto-payment')
+ },
+ autoPaymentDelete() {
+ axios.delete('/api/v4/payments/mandate')
+ .then(response => {
+ this.mandate = { amount: 10, balance: 0 }
+ if (response.data.status == 'success') {
+ this.$toast.success(response.data.message)
+ }
+ })
+ },
+ autoPaymentForm(event, title) {
+ this.paymentForm = 'auto'
+ this.paymentDialogTitle = title || 'Add auto-payment'
+ setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10)
+ },
+ stripeInit() {
+ let script = $('#stripe-script')
+
+ if (!script.length) {
+ script = document.createElement('script')
+
+ script.onload = () => {
+ this.stripe = Stripe(window.config.stripePK)
+ }
+
+ script.id = 'stripe-script'
+ script.src = 'https://js.stripe.com/v3/'
+
+ document.getElementsByTagName('head')[0].appendChild(script)
+ } else {
+ this.stripe = Stripe(window.config.stripePK)
+ }
+ },
+ stripeCheckout(data) {
+ if (!this.stripe) {
+ return
+ }
+
+ this.stripe.redirectToCheckout({
+ sessionId: data.id
+ }).then(result => {
+ // If it fails due to a browser or network error,
+ // display the localized error message to the user
+ if (result.error) {
+ this.$toast.error(result.error.message)
+ }
+ })
}
}
}
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -70,6 +70,10 @@
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::post('payments', 'API\V4\PaymentsController@store');
+ Route::get('payments/mandate', 'API\V4\PaymentsController@mandate');
+ Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate');
+ Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate');
+ Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete');
}
);
@@ -78,7 +82,7 @@
'domain' => \config('app.domain'),
],
function () {
- Route::post('webhooks/payment/mollie', 'API\V4\PaymentsController@webhook');
+ Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook');
}
);
diff --git a/src/tests/Browser.php b/src/tests/Browser.php
--- a/src/tests/Browser.php
+++ b/src/tests/Browser.php
@@ -28,7 +28,7 @@
}
}
- Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $count");
+ Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count");
return $this;
}
diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php
--- a/src/tests/Browser/Admin/UserTest.php
+++ b/src/tests/Browser/Admin/UserTest.php
@@ -107,12 +107,16 @@
// Assert Finances tab
$browser->assertSeeIn('@nav #tab-finances', 'Finances')
->with('@user-finances', function (Browser $browser) {
- $browser->assertSeeIn('.card-title', 'Account balance')
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title', 'Account balance')
->assertSeeIn('.card-title .text-success', '0,00 CHF')
->with('form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 1)
+ $payment_provider = ucfirst(\config('services.payment_provider'));
+ $browser->assertElementsCount('.row', 2)
->assertSeeIn('.row:nth-child(1) label', 'Discount')
- ->assertSeeIn('.row:nth-child(1) #discount span', 'none');
+ ->assertSeeIn('.row:nth-child(1) #discount span', 'none')
+ ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID')
+ ->assertVisible('.row:nth-child(2) a');
});
});
@@ -211,10 +215,11 @@
// Assert Finances tab
$browser->assertSeeIn('@nav #tab-finances', 'Finances')
->with('@user-finances', function (Browser $browser) {
- $browser->assertSeeIn('.card-title', 'Account balance')
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title', 'Account balance')
->assertSeeIn('.card-title .text-danger', '-20,10 CHF')
->with('form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 1)
+ $browser->assertElementsCount('.row', 2)
->assertSeeIn('.row:nth-child(1) label', 'Discount')
->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher');
});
@@ -291,10 +296,11 @@
// Assert Finances tab
$browser->assertSeeIn('@nav #tab-finances', 'Finances')
->with('@user-finances', function (Browser $browser) {
- $browser->assertSeeIn('.card-title', 'Account balance')
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('.card-title', 'Account balance')
->assertSeeIn('.card-title .text-success', '0,00 CHF')
->with('form', function (Browser $browser) {
- $browser->assertElementsCount('.row', 1)
+ $browser->assertElementsCount('.row', 2)
->assertSeeIn('.row:nth-child(1) label', 'Discount')
->assertSeeIn('.row:nth-child(1) #discount span', 'none');
});
@@ -412,6 +418,7 @@
$browser->visit(new UserPage($john->id))
->pause(100)
+ ->waitUntilMissing('@user-finances .app-loader')
->click('@user-finances #discount button')
// Test dialog content, and closing it with Cancel button
->with(new Dialog('#discount-dialog'), function (Browser $browser) {
diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php
--- a/src/tests/Browser/Pages/DomainInfo.php
+++ b/src/tests/Browser/Pages/DomainInfo.php
@@ -25,8 +25,7 @@
*/
public function assert($browser)
{
- $browser->waitUntilMissing('@app .app-loader')
- ->assertPresent('@config,@verify');
+ $browser->waitUntilMissing('@app .app-loader');
}
/**
diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php
--- a/src/tests/Browser/Pages/Home.php
+++ b/src/tests/Browser/Pages/Home.php
@@ -51,11 +51,17 @@
* @param string $username User name
* @param string $password User password
* @param bool $wait_for_dashboard
+ * @param array $config Client-site config
*
* @return void
*/
- public function submitLogon($browser, $username, $password, $wait_for_dashboard = false)
- {
+ public function submitLogon(
+ $browser,
+ $username,
+ $password,
+ $wait_for_dashboard = false,
+ $config = []
+ ) {
$browser->type('@email-input', $username)
->type('@password-input', $password);
@@ -64,6 +70,12 @@
$browser->type('@second-factor-input', $code);
}
+ if (!empty($config)) {
+ $browser->script(
+ sprintf('Object.assign(window.config, %s)', \json_encode($config))
+ );
+ }
+
$browser->press('form button');
if ($wait_for_dashboard) {
diff --git a/src/tests/Browser/Pages/PaymentMollie.php b/src/tests/Browser/Pages/PaymentMollie.php
--- a/src/tests/Browser/Pages/PaymentMollie.php
+++ b/src/tests/Browser/Pages/PaymentMollie.php
@@ -43,4 +43,22 @@
'@status-table' => 'table.table--select-status',
];
}
+
+ /**
+ * Submit payment form.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function submitValidCreditCard($browser)
+ {
+ if ($browser->element('@methods')) {
+ $browser->click('@methods button.grid-button-creditcard')
+ ->waitFor('button.form__button');
+ }
+
+ $browser->click('@status-table input[value="paid"]')
+ ->click('button.form__button');
+ }
}
diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Pages/PaymentStripe.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Tests\Browser\Pages;
+
+use Laravel\Dusk\Page;
+
+class PaymentStripe extends Page
+{
+ /**
+ * Get the URL for the page.
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return '';
+ }
+
+ /**
+ * Assert that the browser is on the page.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function assert($browser)
+ {
+ $browser->waitFor('.App-Payment');
+ }
+
+ /**
+ * Get the element shortcuts for the page.
+ *
+ * @return array
+ */
+ public function elements(): array
+ {
+ return [
+ '@form' => '.App-Payment > form',
+ '@title' => '.App-Overview .ProductSummary-Info .Text',
+ '@amount' => '#ProductSummary-TotalAmount',
+ '@description' => '#ProductSummary-Description',
+ '@email-input' => '.App-Payment #email',
+ '@cardnumber-input' => '.App-Payment #cardNumber',
+ '@cardexpiry-input' => '.App-Payment #cardExpiry',
+ '@cardcvc-input' => '.App-Payment #cardCvc',
+ '@name-input' => '.App-Payment #billingName',
+ '@submit-button' => '.App-Payment form button.SubmitButton',
+ ];
+ }
+
+ /**
+ * Submit payment form.
+ *
+ * @param \Laravel\Dusk\Browser $browser The browser object
+ *
+ * @return void
+ */
+ public function submitValidCreditCard($browser)
+ {
+ $browser->type('@name-input', 'Test')
+ ->type('@cardnumber-input', '4242424242424242')
+ ->type('@cardexpiry-input', '12/' . (date('y') + 1))
+ ->type('@cardcvc-input', '123')
+ ->press('@submit-button');
+ }
+}
diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php
--- a/src/tests/Browser/Pages/Wallet.php
+++ b/src/tests/Browser/Pages/Wallet.php
@@ -39,7 +39,8 @@
{
return [
'@app' => '#app',
- '@main' => '#wallet'
+ '@main' => '#wallet',
+ '@payment-dialog' => '#payment-dialog',
];
}
}
diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/PaymentMollieTest.php
@@ -0,0 +1,212 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\Providers\PaymentProvider;
+use App\Wallet;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\PaymentMollie;
+use Tests\Browser\Pages\Wallet as WalletPage;
+use Tests\TestCaseDusk;
+
+class PaymentMollieTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('payment-test@kolabnow.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('payment-test@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test the payment process
+ *
+ * @group mollie
+ */
+ public function testPayment(): void
+ {
+ $user = $this->getTestUser('payment-test@kolabnow.com', [
+ 'password' => 'simple123',
+ ]);
+
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
+ ->on(new Dashboard())
+ ->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('@main button', 'Add credit')
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->assertFocused('#amount')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@body #payment-form button', 'Continue')
+ // Test error handling
+ ->type('@body #amount', 'aaa')
+ ->click('@body #payment-form button')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #amount', '12.34')
+ ->click('@body #payment-form button');
+ })
+ ->on(new PaymentMollie())
+ ->assertSeeIn('@title', \config('app.name') . ' Payment')
+ ->assertSeeIn('@amount', 'CHF 12.34');
+
+ // Looks like the Mollie testing mode is limited.
+ // We'll select credit card method and mark the payment as paid
+ // We can't do much more, we have to trust Mollie their page works ;)
+
+ // For some reason I don't get the method selection form, it
+ // immediately jumps to the next step. Let's detect that
+ if ($browser->element('@methods')) {
+ $browser->click('@methods button.grid-button-creditcard')
+ ->waitFor('button.form__button');
+ }
+
+ $browser->click('@status-table input[value="paid"]')
+ ->click('button.form__button');
+
+ // Now it should redirect back to wallet page and in background
+ // use the webhook to update payment status (and balance).
+
+ // Looks like in test-mode the webhook is executed before redirect
+ // so we can expect balance updated on the wallet page
+
+ $browser->waitForLocation('/wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF');
+ });
+ }
+
+ /**
+ * Test the auto-payment setup process
+ *
+ * @group mollie
+ */
+ public function testAutoPaymentSetup(): void
+ {
+ $user = $this->getTestUser('payment-test@kolabnow.com', [
+ 'password' => 'simple123',
+ ]);
+
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
+ ->on(new Dashboard())
+ ->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment')
+ ->click('@body #mandate-form button')
+ ->assertSeeIn('@title', 'Add auto-payment')
+ ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
+ ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
+ ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
+ ->assertValue('@body #mandate_balance', '0')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Continue')
+ // Test error handling
+ ->type('@body #mandate_amount', 'aaa')
+ ->type('@body #mandate_balance', '-1')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertVisible('@body #mandate_balance.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
+ ->type('@body #mandate_amount', 'aaa')
+ ->type('@body #mandate_balance', '0')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertMissing('@body #mandate_balance.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ ->assertMissing('#mandate_balance + span + .invalid-feedback')
+ // Submit valid data
+ ->type('@body #mandate_amount', '100')
+ ->type('@body #mandate_balance', '0')
+ ->click('@button-action');
+ })
+ ->on(new PaymentMollie())
+ ->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup')
+ ->assertMissing('@amount')
+ ->submitValidCreditCard()
+ ->waitForLocation('/wallet')
+ ->visit('/wallet?paymentProvider=mollie')
+ ->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
+ . ' time your account balance gets under 0 CHF. You will be charged'
+ . ' via Mastercard (**** **** **** 6787).';
+
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->waitFor('#mandate-info')
+ ->assertSeeIn('#mandate-info p:first-child', $expected)
+ ->click('@button-cancel');
+ });
+ });
+
+ // Test updating auto-payment
+ $this->browse(function (Browser $browser) use ($user) {
+ $browser->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
+ ->click('@body #mandate-info button.btn-primary')
+ ->assertSeeIn('@title', 'Update auto-payment')
+ ->assertValue('@body #mandate_amount', '100')
+ ->assertValue('@body #mandate_balance', '0')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ // Test error handling
+ ->type('@body #mandate_amount', 'aaa')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #mandate_amount', '50')
+ ->click('@button-action');
+ })
+ ->waitUntilMissing('#payment-dialog')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.');
+ });
+
+ // Test deleting auto-payment
+ $this->browse(function (Browser $browser) use ($user) {
+ $browser->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment')
+ ->click('@body #mandate-info button.btn-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
+ ->assertVisible('@body #mandate-form')
+ ->assertMissing('@body #mandate-info');
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/PaymentStripeTest.php
@@ -0,0 +1,202 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\Providers\PaymentProvider;
+use App\Wallet;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\PaymentStripe;
+use Tests\Browser\Pages\Wallet as WalletPage;
+use Tests\TestCaseDusk;
+
+class PaymentStripeTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('payment-test@kolabnow.com');
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('payment-test@kolabnow.com');
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test the payment process
+ *
+ * @group stripe
+ */
+ public function testPayment(): void
+ {
+ $user = $this->getTestUser('payment-test@kolabnow.com', [
+ 'password' => 'simple123',
+ ]);
+
+ $this->browse(function (Browser $browser) use ($user) {
+ $browser->visit(new Home())
+ ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
+ ->on(new Dashboard())
+ ->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('@main button', 'Add credit')
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->assertFocused('#amount')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@body #payment-form button', 'Continue')
+ // Test error handling
+ ->type('@body #amount', 'aaa')
+ ->click('@body #payment-form button')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #amount', '12.34')
+ ->click('@body #payment-form button');
+ })
+ ->on(new PaymentStripe())
+ ->assertSeeIn('@title', \config('app.name') . ' Payment')
+ ->assertSeeIn('@amount', 'CHF 12.34')
+ ->assertValue('@email-input', $user->email)
+ ->submitValidCreditCard();
+
+ // Now it should redirect back to wallet page and in background
+ // use the webhook to update payment status (and balance).
+
+ // Looks like in test-mode the webhook is executed before redirect
+ // so we can expect balance updated on the wallet page
+
+ $browser->waitForLocation('/wallet', 15) // need more time than default 5 sec.
+ ->on(new WalletPage())
+ ->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF');
+ });
+ }
+
+ /**
+ * Test the auto-payment setup process
+ *
+ * @group stripe
+ */
+ public function testAutoPaymentSetup(): void
+ {
+ $user = $this->getTestUser('payment-test@kolabnow.com', [
+ 'password' => 'simple123',
+ ]);
+
+ // Test creating auto-payment
+ $this->browse(function (Browser $browser) use ($user) {
+ $browser->visit(new Home())
+ ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
+ ->on(new Dashboard())
+ ->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment')
+ ->click('@body #mandate-form button')
+ ->assertSeeIn('@title', 'Add auto-payment')
+ ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
+ ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
+ ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
+ ->assertValue('@body #mandate_balance', '0')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Continue')
+ // Test error handling
+ ->type('@body #mandate_amount', 'aaa')
+ ->type('@body #mandate_balance', '-1')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertVisible('@body #mandate_balance.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
+ ->type('@body #mandate_amount', 'aaa')
+ ->type('@body #mandate_balance', '0')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertMissing('@body #mandate_balance.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ ->assertMissing('#mandate_balance + span + .invalid-feedback')
+ // Submit valid data
+ ->type('@body #mandate_amount', '100')
+ ->type('@body #mandate_balance', '0')
+ ->click('@button-action');
+ })
+ ->on(new PaymentStripe())
+ ->assertMissing('@title')
+ ->assertMissing('@amount')
+ ->assertValue('@email-input', $user->email)
+ ->submitValidCreditCard()
+ ->waitForLocation('/wallet', 15) // need more time than default 5 sec.
+ ->visit('/wallet?paymentProvider=stripe')
+ ->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $expected = 'Auto-payment is set to fill up your account by 100 CHF every'
+ . ' time your account balance gets under 0 CHF. You will be charged'
+ . ' via Visa (**** **** **** 4242).';
+
+ $browser->assertSeeIn('@title', 'Top up your wallet')
+ ->waitFor('#mandate-info')
+ ->assertSeeIn('#mandate-info p:first-child', $expected)
+ ->click('@button-cancel');
+ });
+ });
+
+ // Test updating auto-payment
+ $this->browse(function (Browser $browser) use ($user) {
+ $browser->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment')
+ ->click('@body #mandate-info button.btn-primary')
+ ->assertSeeIn('@title', 'Update auto-payment')
+ ->assertValue('@body #mandate_amount', '100')
+ ->assertValue('@body #mandate_balance', '0')
+ ->assertSeeIn('@button-cancel', 'Cancel')
+ ->assertSeeIn('@button-action', 'Submit')
+ // Test error handling
+ ->type('@body #mandate_amount', 'aaa')
+ ->click('@button-action')
+ ->assertToast(Toast::TYPE_ERROR, 'Form validation error')
+ ->assertVisible('@body #mandate_amount.is-invalid')
+ ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
+ // Submit valid data
+ ->type('@body #mandate_amount', '50')
+ ->click('@button-action');
+ })
+ ->waitUntilMissing('#payment-dialog')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.');
+ });
+
+ // Test deleting auto-payment
+ $this->browse(function (Browser $browser) use ($user) {
+ $browser->on(new WalletPage())
+ ->click('@main button')
+ ->with(new Dialog('@payment-dialog'), function (Browser $browser) {
+ $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment')
+ ->click('@body #mandate-info button.btn-danger')
+ ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
+ ->assertVisible('@body #mandate-form')
+ ->assertMissing('@body #mandate-info');
+ });
+ });
+ }
+}
diff --git a/src/tests/Browser/PaymentTest.php b/src/tests/Browser/PaymentTest.php
deleted file mode 100644
--- a/src/tests/Browser/PaymentTest.php
+++ /dev/null
@@ -1,82 +0,0 @@
-<?php
-
-namespace Tests\Browser;
-
-use App\Wallet;
-use Tests\Browser;
-use Tests\Browser\Pages\Dashboard;
-use Tests\Browser\Pages\Home;
-use Tests\Browser\Pages\PaymentMollie;
-use Tests\Browser\Pages\Wallet as WalletPage;
-use Tests\TestCaseDusk;
-
-class PaymentTest extends TestCaseDusk
-{
- /**
- * {@inheritDoc}
- */
- public function setUp(): void
- {
- parent::setUp();
-
- $this->deleteTestUser('payment-test@kolabnow.com');
- }
-
- /**
- * {@inheritDoc}
- */
- public function tearDown(): void
- {
- $this->deleteTestUser('payment-test@kolabnow.com');
-
- parent::tearDown();
- }
-
- /**
- * Test a payment process
- *
- * @group mollie
- */
- public function testPayment(): void
- {
- $user = $this->getTestUser('payment-test@kolabnow.com', [
- 'password' => 'simple123',
- ]);
-
- $this->browse(function (Browser $browser) {
- $browser->visit(new Home())
- ->submitLogon('payment-test@kolabnow.com', 'simple123', true)
- ->on(new Dashboard())
- ->click('@links .link-wallet')
- ->on(new WalletPage())
- ->click('@main button')
- ->on(new PaymentMollie())
- ->assertSeeIn('@title', 'Kolab Now Payment')
- ->assertSeeIn('@amount', 'CHF 10.00');
-
- // Looks like the Mollie testing mode is limited.
- // We'll select credit card method and mark the payment as paid
- // We can't do much more, we have to trust Mollie their page works ;)
-
- // For some reason I don't get the method selection form, it
- // immediately jumps to the next step. Let's detect that
- if ($browser->element('@methods')) {
- $browser->click('@methods button.grid-button-creditcard')
- ->waitFor('button.form__button');
- }
-
- $browser->click('@status-table input[value="paid"]')
- ->click('button.form__button');
-
- // Now it should redirect back to wallet page and in background
- // use the webhook to update payment status (and balance).
-
- // Looks like in test-mode the webhook is executed before redirect
- // so we can expect balance updated on the wallet page
-
- $browser->waitForLocation('/wallet')
- ->on(new WalletPage())
- ->assertSeeIn('@main .card-text', 'Current account balance is 10,00 CHF');
- });
- }
-}
diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php
--- a/src/tests/Feature/Backends/LDAPTest.php
+++ b/src/tests/Feature/Backends/LDAPTest.php
@@ -108,7 +108,6 @@
$expected['inetuserstatus'] = $user->status;
$expected['mailquota'] = 2097152;
$expected['nsroledn'] = null;
- // TODO: country? dn
$ldap_user = LDAP::getUser($user->email);
diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Admin/WalletsTest.php
@@ -25,6 +25,46 @@
}
/**
+ * Test fetching a wallet (GET /api/v4/wallets/:id)
+ *
+ * @group stripe
+ */
+ public function testShow(): void
+ {
+ \config(['services.payment_provider' => 'stripe']);
+
+ $user = $this->getTestUser('john@kolab.org');
+ $admin = $this->getTestUser('jeroen@jeroen.jeroen');
+ $wallet = $user->wallets()->first();
+
+ // Make sure there's no stripe/mollie identifiers
+ $wallet->setSetting('stripe_id', null);
+ $wallet->setSetting('stripe_mandate_id', null);
+ $wallet->setSetting('mollie_id', null);
+ $wallet->setSetting('mollie_mandate_id', null);
+
+ // Non-admin user
+ $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}");
+ $response->assertStatus(403);
+
+ // Admin user
+ $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame($wallet->id, $json['id']);
+ $this->assertSame('CHF', $json['currency']);
+ $this->assertSame(0, $json['balance']);
+ $this->assertSame(0, $json['discount']);
+ $this->assertTrue(empty($json['description']));
+ $this->assertTrue(empty($json['discount_description']));
+ $this->assertTrue(!empty($json['provider']));
+ $this->assertTrue(!empty($json['providerLink']));
+ $this->assertTrue(!empty($json['mandate']));
+ }
+
+ /**
* Test updating a wallet (PUT /api/v4/wallets/:id)
*/
public function testUpdate(): void
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -0,0 +1,325 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\V4\PaymentsController;
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\Wallet;
+use App\WalletSetting;
+use GuzzleHttp\Psr7\Response;
+use Tests\TestCase;
+
+class PaymentsMollieTest extends TestCase
+{
+ use \Tests\MollieMocksTrait;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // All tests in this file use Mollie
+ \config(['services.payment_provider' => 'mollie']);
+
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $john->setSetting('mollie_id', null);
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $john->setSetting('mollie_id', null);
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+
+ 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');
+
+ // 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 = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
+ $this->assertSame("Minimum amount for a single payment is {$min}.", $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);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
+
+ // 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']);
+
+ $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' => $json['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)));
+
+ $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']);
+
+ // 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)
+ $post = ['amount' => 30.10, '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']);
+
+ $wallet = $user->wallets()->first();
+
+ $this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
+ $this->assertEquals(1, $wallet->getSetting('mandate_balance'));
+
+ $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 creating a payment and receiving a status via webhook
+ *
+ * @group mollie
+ */
+ public function testStoreAndWebhook(): void
+ {
+ // Unauth access not allowed
+ $response = $this->post("api/v4/payments", []);
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ $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 = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
+ $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
+
+ $post = ['amount' => '12.34'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
+
+ $wallet = $user->wallets()->first();
+ $payments = Payment::where('wallet_id', $wallet->id)->get();
+
+ $this->assertCount(1, $payments);
+ $payment = $payments[0];
+ $this->assertSame(1234, $payment->amount);
+ $this->assertSame(\config('app.name') . ' 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('paid', $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+
+ // 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('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('paid', $payment->fresh()->status);
+ $this->assertEquals(1234, $wallet->fresh()->balance);
+ }
+
+ /**
+ * Test automatic payment charges
+ *
+ * @group mollie
+ */
+ public function testDirectCharge(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $wallet = $user->wallets()->first();
+
+ // Expect false result, as there's no mandate
+ $result = PaymentsController::directCharge($wallet, 1234);
+ $this->assertFalse($result);
+
+ // Problem with this is we need to have a valid mandate
+ // And 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 some actions with use of Dusk browser.
+
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -0,0 +1,274 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\V4\PaymentsController;
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\Wallet;
+use App\WalletSetting;
+use GuzzleHttp\Psr7\Response;
+use Tests\TestCase;
+
+class PaymentsStripeTest extends TestCase
+{
+ use \Tests\StripeMocksTrait;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // All tests in this file use Stripe
+ \config(['services.payment_provider' => 'stripe']);
+
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $john->setSetting('mollie_id', null);
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $john->wallets()->first();
+ $john->setSetting('mollie_id', null);
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test creating/updating/deleting an outo-payment mandate
+ *
+ * @group stripe
+ */
+ 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');
+
+ // 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 = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
+ $this->assertSame("Minimum amount for a single payment is {$min}.", $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);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^cs_test_|', $json['id']);
+
+ // 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']);
+
+ // 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"
+ }';
+
+ $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');
+
+ $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']);
+
+ // 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)
+ $post = ['amount' => 30.10, '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->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
+ $this->assertEquals(1, $wallet->getSetting('mandate_balance'));
+
+ $this->unmockStripe();
+
+ // TODO: Delete mandate
+ }
+
+ /**
+ * Test creating a payment and receiving a status via webhook
+ *
+ * @group stripe
+ */
+ public function testStoreAndWebhook(): void
+ {
+ // Unauth access not allowed
+ $response = $this->post("api/v4/payments", []);
+ $response->assertStatus(401);
+
+ $user = $this->getTestUser('john@kolab.org');
+
+ $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 = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
+ $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
+
+ $post = ['amount' => '12.34'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^cs_test_|', $json['id']);
+
+ $wallet = $user->wallets()->first();
+ $payments = Payment::where('wallet_id', $wallet->id)->get();
+
+ $this->assertCount(1, $payments);
+ $payment = $payments[0];
+ $this->assertSame(1234, $payment->amount);
+ $this->assertSame(\config('app.name') . ' Payment', $payment->description);
+ $this->assertSame('open', $payment->status);
+ $this->assertEquals(0, $wallet->balance);
+
+ // TODO: Test the webhook
+ }
+
+ /**
+ * Test automatic payment charges
+ *
+ * @group stripe
+ */
+ public function testDirectCharge(): void
+ {
+ $user = $this->getTestUser('john@kolab.org');
+ $wallet = $user->wallets()->first();
+
+ // Expect false result, as there's no mandate
+ $result = PaymentsController::directCharge($wallet, 1234);
+ $this->assertFalse($result);
+
+ // Problem with this is we need to have a valid mandate
+ // And 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 some actions with use of Dusk browser.
+
+ $this->markTestIncomplete();
+ }
+}
diff --git a/src/tests/Feature/Controller/PaymentsTest.php b/src/tests/Feature/Controller/PaymentsTest.php
deleted file mode 100644
--- a/src/tests/Feature/Controller/PaymentsTest.php
+++ /dev/null
@@ -1,156 +0,0 @@
-<?php
-
-namespace Tests\Feature\Controller;
-
-use App\Payment;
-use App\Wallet;
-use GuzzleHttp\Psr7\Response;
-use Tests\TestCase;
-
-class PaymentsTest extends TestCase
-{
- use \Tests\MollieMocksTrait;
-
- /**
- * {@inheritDoc}
- */
- public function setUp(): void
- {
- parent::setUp();
-
- $john = $this->getTestUser('john@kolab.org');
- $wallet = $john->wallets()->first();
- $john->setSetting('mollie_id', null);
- Payment::where('wallet_id', $wallet->id)->delete();
- Wallet::where('id', $wallet->id)->update(['balance' => 0]);
- }
-
- /**
- * {@inheritDoc}
- */
- public function tearDown(): void
- {
- $john = $this->getTestUser('john@kolab.org');
- $wallet = $john->wallets()->first();
- $john->setSetting('mollie_id', null);
- Payment::where('wallet_id', $wallet->id)->delete();
- Wallet::where('id', $wallet->id)->update(['balance' => 0]);
-
- parent::tearDown();
- }
-
- /**
- * Test creating a payment and receiving a status via webhook)
- *
- * @group mollie
- */
- public function testStoreAndWebhook(): void
- {
- // Unauth access not allowed
- $response = $this->post("api/v4/payments", []);
- $response->assertStatus(401);
-
- $user = $this->getTestUser('john@kolab.org');
-
- $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']);
- $this->assertSame('The amount must be at least 1.', $json['errors']['amount'][0]);
-
- $post = ['amount' => 1234];
- $response = $this->actingAs($user)->post("api/v4/payments", $post);
- $response->assertStatus(200);
-
- $json = $response->json();
-
- $this->assertSame('success', $json['status']);
- $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
-
- $wallet = $user->wallets()->first();
- $payments = Payment::where('wallet_id', $wallet->id)->get();
-
- $this->assertCount(1, $payments);
- $payment = $payments[0];
- $this->assertSame(1234, $payment->amount);
- $this->assertSame('Kolab Now 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",
-/*
- "createdAt" => "2018-03-20T13:13:37+00:00",
- "amount" => {
- "value" => "10.00",
- "currency" => "EUR"
- },
- "description" => "Order #12345",
- "method" => null,
- "metadata" => {
- "order_id" => "12345"
- },
- "isCancelable" => false,
- "locale" => "nl_NL",
- "restrictPaymentMethodsToCountry" => "NL",
- "expiresAt" => "2018-03-20T13:28:37+00:00",
- "details" => null,
- "profileId" => "pfl_QkEhN94Ba",
- "sequenceType" => "oneoff",
- "redirectUrl" => "https://webshop.example.org/order/12345/",
- "webhookUrl" => "https://webshop.example.org/payments/webhook/",
-*/
- ];
-
- // 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('paid', $payment->fresh()->status);
- $this->assertEquals(1234, $wallet->fresh()->balance);
-
- // 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('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('paid', $payment->fresh()->status);
- $this->assertEquals(1234, $wallet->fresh()->balance);
- }
-
- public function testDirectCharge(): void
- {
- $this->markTestIncomplete();
- }
-}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -33,6 +33,7 @@
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
+ $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
@@ -53,6 +54,7 @@
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
+ $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->status |= User::STATUS_IMAP_READY;
$user->save();
@@ -316,16 +318,12 @@
$json = $response->json();
$this->assertTrue($json['isImapReady']);
- $this->assertFalse($json['isReady']);
+ $this->assertTrue($json['isReady']);
$this->assertCount(7, $json['process']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(true, $json['process'][2]['state']);
- $this->assertSame('domain-confirmed', $json['process'][6]['label']);
- $this->assertSame(false, $json['process'][6]['state']);
- $this->assertSame('error', $json['status']);
- $this->assertSame('Failed to verify an ownership of a domain.', $json['message']);
-
- // TODO: Test completing all process steps
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('Setup process finished successfully.', $json['message']);
}
/**
@@ -728,6 +726,7 @@
*/
public function testUserResponse(): void
{
+ $provider = \config('payment_provider') ?: 'mollie';
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
@@ -765,11 +764,15 @@
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertSame($wallet->id, $result['accounts'][0]['id']);
$this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
+ $this->assertSame($provider, $result['wallet']['provider']);
+ $this->assertSame($provider, $result['wallets'][0]['provider']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
$wallet->discount()->associate($discount);
$wallet->save();
+ $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie';
+ $wallet->setSetting($mod_provider . '_id', 123);
$user->refresh();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]);
@@ -778,9 +781,11 @@
$this->assertSame($discount->id, $result['wallet']['discount_id']);
$this->assertSame($discount->discount, $result['wallet']['discount']);
$this->assertSame($discount->description, $result['wallet']['discount_description']);
+ $this->assertSame($mod_provider, $result['wallet']['provider']);
$this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
$this->assertSame($discount->discount, $result['wallets'][0]['discount']);
$this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
+ $this->assertSame($mod_provider, $result['wallets'][0]['provider']);
}
/**
diff --git a/src/tests/MollieMocksTrait.php b/src/tests/MollieMocksTrait.php
--- a/src/tests/MollieMocksTrait.php
+++ b/src/tests/MollieMocksTrait.php
@@ -31,9 +31,9 @@
$guzzle = new Client(['handler' => $handler]);
- $this->app->forgetInstance('mollie.api.client');
- $this->app->forgetInstance('mollie.api');
- $this->app->forgetInstance('mollie');
+ $this->app->forgetInstance('mollie.api.client'); // @phpstan-ignore-line
+ $this->app->forgetInstance('mollie.api'); // @phpstan-ignore-line
+ $this->app->forgetInstance('mollie'); // @phpstan-ignore-line
$this->app->singleton('mollie.api.client', function () use ($guzzle) {
return new MollieApiClient($guzzle);
@@ -44,8 +44,14 @@
public function unmockMollie()
{
- $this->app->forgetInstance('mollie.api.client');
- $this->app->forgetInstance('mollie.api');
- $this->app->forgetInstance('mollie');
+ $this->app->forgetInstance('mollie.api.client'); // @phpstan-ignore-line
+ $this->app->forgetInstance('mollie.api'); // @phpstan-ignore-line
+ $this->app->forgetInstance('mollie'); // @phpstan-ignore-line
+
+ $guzzle = new Client();
+
+ $this->app->singleton('mollie.api.client', function () use ($guzzle) {
+ return new MollieApiClient($guzzle);
+ });
}
}
diff --git a/src/tests/StripeMockClient.php b/src/tests/StripeMockClient.php
new file mode 100644
--- /dev/null
+++ b/src/tests/StripeMockClient.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests;
+
+use Stripe as StripeAPI;
+
+class StripeMockClient implements StripeAPI\HttpClient\ClientInterface
+{
+ private $responses = [];
+
+ public function request($method, $absUrl, $headers, $params, $hasFile)
+ {
+ $response = array_shift($this->responses);
+
+ return $response;
+ }
+
+ public function addResponse($body, $code = 200, $headers = [])
+ {
+ $this->responses[] = [$body, $code, $headers];
+ }
+}
diff --git a/src/tests/StripeMocksTrait.php b/src/tests/StripeMocksTrait.php
new file mode 100644
--- /dev/null
+++ b/src/tests/StripeMocksTrait.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Tests;
+
+use Stripe as StripeAPI;
+
+trait StripeMocksTrait
+{
+ /**
+ * Mock Stripe's HTTP client
+ *
+ * @return \Tests\StripeMockClient
+ */
+ public function mockStripe()
+ {
+ $mockClient = new StripeMockClient();
+ StripeAPI\ApiRequestor::setHttpClient($mockClient);
+
+ return $mockClient;
+ }
+
+ public function unmockStripe()
+ {
+ $curlClient = StripeAPI\HttpClient\CurlClient::instance();
+ StripeAPI\ApiRequestor::setHttpClient($curlClient);
+ }
+}
diff --git a/src/tests/TestCase.php b/src/tests/TestCase.php
--- a/src/tests/TestCase.php
+++ b/src/tests/TestCase.php
@@ -26,6 +26,6 @@
// If we wanted to access both user and admin in one test
// we can also just call post/get/whatever with full url
\config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]);
- url()->forceRootUrl(config('app.url'));
+ url()->forceRootUrl(config('app.url')); // @phpstan-ignore-line
}
}
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Thu, Apr 2, 10:38 PM (9 h, 42 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18782772
Default Alt Text
D1231.1775169528.diff (161 KB)
Attached To
Mode
D1231: Custom payments, auto-payments, Stripe support
Attached
Detach File
Event Timeline