Page MenuHomePhorge

D1231.1775169528.diff
No OneTemporary

Authored By
Unknown
Size
161 KB
Referenced Files
None
Subscribers
None

D1231.1775169528.diff

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">&times;</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

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)

Event Timeline