Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117893935
D4202.1775368305.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
49 KB
Referenced Files
None
Subscribers
None
D4202.1775368305.diff
View Options
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -7,6 +7,7 @@
use App\Discount;
use App\Domain;
use App\Plan;
+use App\Providers\PaymentProvider;
use App\Rules\SignupExternalEmail;
use App\Rules\SignupToken;
use App\Rules\Password;
@@ -34,27 +35,25 @@
*/
public function plans(Request $request)
{
- $plans = [];
-
// Use reverse order just to have individual on left, group on right ;)
// But prefer monthly on left, yearly on right
- Plan::withEnvTenantContext()->orderBy('months')->orderByDesc('title')->get()
- ->map(function ($plan) use (&$plans) {
- // Allow themes to set custom button label
- $button = \trans('theme::app.planbutton-' . $plan->title);
- if ($button == 'theme::app.planbutton-' . $plan->title) {
- $button = \trans('app.planbutton', ['plan' => $plan->name]);
+ $plans = Plan::withEnvTenantContext()->orderBy('months')->orderByDesc('title')->get()
+ ->map(function ($plan) {
+ $button = self::trans("app.planbutton-{$plan->title}");
+ if (strpos($button, 'app.planbutton') !== false) {
+ $button = self::trans('app.planbutton', ['plan' => $plan->name]);
}
- $plans[] = [
+ return [
'title' => $plan->title,
'name' => $plan->name,
'button' => $button,
'description' => $plan->description,
- 'mode' => $plan->mode ?: 'email',
+ 'mode' => $plan->mode ?: Plan::MODE_EMAIL,
'isDomain' => $plan->hasDomain(),
];
- });
+ })
+ ->all();
return response()->json(['status' => 'success', 'plans' => $plans]);
}
@@ -91,7 +90,7 @@
$plan = $this->getPlan();
- if ($plan->mode == 'token') {
+ if ($plan->mode == Plan::MODE_TOKEN) {
$rules['token'] = ['required', 'string', new SignupToken()];
} else {
$rules['email'] = ['required', 'string', new SignupExternalEmail()];
@@ -106,7 +105,7 @@
// Generate the verification code
$code = SignupCode::create([
- 'email' => $plan->mode == 'token' ? $request->token : $request->email,
+ 'email' => $plan->mode == Plan::MODE_TOKEN ? $request->token : $request->email,
'first_name' => $request->first_name,
'last_name' => $request->last_name,
'plan' => $plan->title,
@@ -119,7 +118,7 @@
'mode' => $plan->mode ?: 'email',
];
- if ($plan->mode == 'token') {
+ if ($plan->mode == Plan::MODE_TOKEN) {
// Token verification, jump to the last step
$has_domain = $plan->hasDomain();
@@ -221,13 +220,13 @@
}
/**
- * Finishes the signup process by creating the user account.
+ * Validates the input to the final signup request.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
- public function signup(Request $request)
+ public function signupValidate(Request $request)
{
// Validate input
$v = Validator::make(
@@ -244,14 +243,13 @@
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
-
$settings = [];
// Plan parameter is required/allowed in mandate mode
if (!empty($request->plan) && empty($request->code) && empty($request->invitation)) {
$plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first();
- if (!$plan || $plan->mode != 'mandate') {
+ if (!$plan || $plan->mode != Plan::MODE_MANDATE) {
$msg = \trans('validation.exists', ['attribute' => 'plan']);
return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422);
}
@@ -300,7 +298,7 @@
'last_name' => $code_data->last_name,
];
- if ($plan->mode == 'token') {
+ if ($plan->mode == Plan::MODE_TOKEN) {
$settings['signup_token'] = $code_data->email;
} else {
$settings['external_email'] = $code_data->email;
@@ -323,17 +321,46 @@
}
$is_domain = $plan->hasDomain();
- $login = $request->login;
- $domain_name = $request->domain;
// Validate login
- if ($errors = self::validateLogin($login, $domain_name, $is_domain)) {
+ if ($errors = self::validateLogin($request->login, $request->domain, $is_domain)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
+ // Set some properties for signup() method
+ $request->settings = $settings;
+ $request->plan = $plan;
+ $request->discount = $discount ?? null;
+ $request->invitation = $invitation ?? null;
+
+ $result = [];
+
+ if ($plan->mode == Plan::MODE_MANDATE) {
+ $result = $this->mandateForPlan($plan, $request->discount);
+ }
+
+ return response()->json($result);
+ }
+
+ /**
+ * Finishes the signup process by creating the user account.
+ *
+ * @param \Illuminate\Http\Request $request HTTP request
+ *
+ * @return \Illuminate\Http\JsonResponse JSON response
+ */
+ public function signup(Request $request)
+ {
+ $v = $this->signupValidate($request);
+ if ($v->status() !== 200) {
+ return $v;
+ }
+
+ $is_domain = $request->plan->hasDomain();
+
// We allow only ASCII, so we can safely lower-case the email address
- $login = Str::lower($login);
- $domain_name = Str::lower($domain_name);
+ $login = Str::lower($request->login);
+ $domain_name = Str::lower($request->domain);
$domain = null;
DB::beginTransaction();
@@ -353,22 +380,22 @@
'status' => User::STATUS_RESTRICTED,
]);
- if (!empty($discount)) {
+ if ($request->discount) {
$wallet = $user->wallets()->first();
- $wallet->discount()->associate($discount);
+ $wallet->discount()->associate($request->discount);
$wallet->save();
}
- $user->assignPlan($plan, $domain);
+ $user->assignPlan($request->plan, $domain);
// Save the external email and plan in user settings
- $user->setSettings($settings);
+ $user->setSettings($request->settings);
// Update the invitation
- if (!empty($invitation)) {
- $invitation->status = SignupInvitation::STATUS_COMPLETED;
- $invitation->user_id = $user->id;
- $invitation->save();
+ if ($request->invitation) {
+ $request->invitation->status = SignupInvitation::STATUS_COMPLETED;
+ $request->invitation->user_id = $user->id;
+ $request->invitation->save();
}
// Soft-delete the verification code, and store some more info with it
@@ -384,14 +411,67 @@
$response = AuthController::logonResponse($user, $request->password);
- // Redirect the user to the specified page
- // $data = $response->getData(true);
- // $data['redirect'] = 'wallet';
- // $response->setData($data);
+ if ($request->plan->mode == Plan::MODE_MANDATE) {
+ $data = $response->getData(true);
+ $data['checkout'] = $this->mandateForPlan($request->plan, $request->discount, $user);
+ $response->setData($data);
+ }
return $response;
}
+ /**
+ * Collects some content to display to the user before redirect to a checkout page.
+ * Optionally creates a recurrent payment mandate for specified user/plan.
+ */
+ protected function mandateForPlan(Plan $plan, Discount $discount = null, User $user = null): array
+ {
+ $result = [];
+
+ $min = \App\Payment::MIN_AMOUNT;
+ $planCost = $plan->cost() * $plan->months;
+
+ if ($discount) {
+ $planCost -= ceil($planCost * (100 - $discount->discount) / 100);
+ }
+
+ if ($planCost > $min) {
+ $min = $planCost;
+ }
+
+ if ($user) {
+ $wallet = $user->wallets()->first();
+ $wallet->setSettings([
+ 'mandate_amount' => sprintf('%.2f', round($min / 100, 2)),
+ 'mandate_balance' => 0,
+ ]);
+
+ $mandate = [
+ 'currency' => $wallet->currency,
+ 'description' => \App\Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup',
+ 'methodId' => PaymentProvider::METHOD_CREDITCARD,
+ 'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id),
+ ];
+
+ $provider = PaymentProvider::factory($wallet);
+
+ $result = $provider->createMandate($wallet, $mandate);
+ }
+
+ $params = [
+ 'cost' => \App\Utils::money($planCost, \config('app.currency')),
+ 'period' => \trans($plan->months == 12 ? 'app.period-year' : 'app.period-month'),
+ ];
+
+ $content = '<b>' . self::trans('app.signup-account-tobecreated') . '</b><br><br>'
+ . self::trans('app.signup-account-summary', $params) . '<br><br>'
+ . self::trans('app.signup-account-mandate', $params);
+
+ $result['content'] = $content;
+
+ return $result;
+ }
+
/**
* Returns plan for the signup process
*
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
@@ -139,6 +139,36 @@
return response()->json($result);
}
+ /**
+ * Reset the auto-payment mandate, create a new payment for it.
+ *
+ * @param \Illuminate\Http\Request $request The API request.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function mandateReset(Request $request)
+ {
+ $user = $this->guard()->user();
+
+ // TODO: Wallet selection
+ $wallet = $user->wallets()->first();
+
+ $mandate = [
+ 'currency' => $wallet->currency,
+ 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup',
+ 'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
+ 'redirectUrl' => \App\Utils::serviceUrl('/payment/status', $user->tenant_id),
+ ];
+
+ $provider = PaymentProvider::factory($wallet);
+
+ $result = $provider->createMandate($wallet, $mandate);
+
+ $result['status'] = 'success';
+
+ return response()->json($result);
+ }
+
/**
* Validate an auto-payment mandate request.
*
@@ -172,7 +202,7 @@
$label = 'minamount';
if (($plan = $wallet->plan()) && $plan->months >= 1) {
- $planCost = (int) ceil($plan->cost() * $plan->months);
+ $planCost = $plan->cost() * $plan->months;
if ($planCost > $min) {
$min = $planCost;
}
@@ -190,6 +220,39 @@
return null;
}
+ /**
+ * Get status of the last payment.
+ *
+ * @return \Illuminate\Http\JsonResponse The response
+ */
+ public function paymentStatus()
+ {
+ $user = $this->guard()->user();
+ $wallet = $user->wallets()->first();
+
+ $payment = $wallet->payments()->orderBy('created_at', 'desc')->first();
+
+ if (empty($payment)) {
+ return $this->errorResponse(404);
+ }
+
+ $done = [Payment::STATUS_PAID, Payment::STATUS_CANCELED, Payment::STATUS_FAILED, Payment::STATUS_EXPIRED];
+
+ if (in_array($payment->status, $done)) {
+ $label = "app.payment-status-{$payment->status}";
+ } else {
+ $label = "app.payment-status-checking";
+ }
+
+ return response()->json([
+ 'id' => $payment->id,
+ 'status' => $payment->status,
+ 'type' => $payment->type,
+ 'statusMessage' => \trans($label),
+ 'description' => $payment->description,
+ ]);
+ }
+
/**
* Create a new payment.
*
@@ -386,7 +449,7 @@
// If this is a multi-month plan, we calculate the expected amount to be payed.
if (($plan = $wallet->plan()) && $plan->months >= 1) {
- $planCost = (int) ceil(($plan->cost() * $plan->months) / 100);
+ $planCost = round($plan->cost() * $plan->months / 100, 2);
if ($planCost > $mandate['minAmount']) {
$mandate['minAmount'] = $planCost;
}
@@ -514,6 +577,9 @@
/**
* Calculates tax for the payment, fills the request with additional properties
+ *
+ * @param \App\Wallet $wallet The wallet
+ * @param array $request The request data with the payment amount
*/
protected static function addTax(Wallet $wallet, array &$request): void
{
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
@@ -4,6 +4,7 @@
use App\Http\Controllers\RelationController;
use App\Domain;
+use App\Plan;
use App\Rules\Password;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
@@ -203,7 +204,7 @@
'enableUsers' => $isController,
'enableWallets' => $isController,
'enableWalletMandates' => $isController,
- 'enableWalletPayments' => $isController && (!$plan || $plan->mode != 'mandate'),
+ 'enableWalletPayments' => $isController && (!$plan || $plan->mode != Plan::MODE_MANDATE),
'enableCompanionapps' => $hasBeta,
];
@@ -356,7 +357,7 @@
$wallet = $user->wallet();
// IsLocked flag to lock the user to the Wallet page only
- $response['isLocked'] = ($user->isRestricted() && ($plan = $wallet->plan()) && $plan->mode == 'mandate');
+ $response['isLocked'] = (!$user->isActive() && ($plan = $wallet->plan()) && $plan->mode == Plan::MODE_MANDATE);
// Settings
$response['settings'] = [];
diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php
--- a/src/app/Http/Controllers/Controller.php
+++ b/src/app/Http/Controllers/Controller.php
@@ -81,4 +81,20 @@
{
return Auth::guard();
}
+
+ /**
+ * A wrapper for \trans() with theme localization support.
+ *
+ * @param string $label Localization label
+ * @param array $params Translation parameters
+ */
+ public static function trans(string $label, array $params = []): string
+ {
+ $result = \trans("theme::{$label}", $params);
+ if ($result === "theme::{$label}") {
+ $result = \trans($label, $params);
+ }
+
+ return $result;
+ }
}
diff --git a/src/app/Jobs/User/CreateJob.php b/src/app/Jobs/User/CreateJob.php
--- a/src/app/Jobs/User/CreateJob.php
+++ b/src/app/Jobs/User/CreateJob.php
@@ -102,7 +102,14 @@
$user->status |= \App\User::STATUS_IMAP_READY;
}
- $user->status |= \App\User::STATUS_ACTIVE;
+ // Make user active in non-mandate mode only
+ if (!($wallet = $user->wallet())
+ || !($plan = $user->wallet()->plan())
+ || $plan->mode != \App\Plan::MODE_MANDATE
+ ) {
+ $user->status |= \App\User::STATUS_ACTIVE;
+ }
+
$user->save();
}
}
diff --git a/src/app/Payment.php b/src/app/Payment.php
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -12,6 +12,8 @@
* @property int $credit_amount Amount of money in cents of system currency (wallet balance)
* @property string $description Payment description
* @property string $id Mollie's Payment ID
+ * @property string $status Payment status (Payment::STATUS_*)
+ * @property string $type Payment type (Payment::TYPE_*)
* @property ?string $vat_rate_id VAT rate identifier
* @property \App\Wallet $wallet The wallet
* @property string $wallet_id The ID of the wallet
@@ -126,9 +128,16 @@
$this->wallet->setSetting('mandate_disabled', null);
}
- // Remove RESTRICTED flag from the wallet owner and all users in the wallet
- if ($this->wallet->owner && $this->wallet->owner->isRestricted()) {
- $this->wallet->owner->unrestrict(true);
+ if ($owner = $this->wallet->owner) {
+ // Remove RESTRICTED flag from the wallet owner and all users in the wallet
+ if ($owner->isRestricted()) {
+ $owner->unrestrict(true);
+ }
+ // Activate the inactive user
+ if (!$owner->isActive()) {
+ $owner->status |= User::STATUS_ACTIVE;
+ $owner->save();
+ }
}
}
diff --git a/src/app/Plan.php b/src/app/Plan.php
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -20,7 +20,7 @@
* @property int $discount_rate
* @property int $free_months
* @property string $id
- * @property string $mode Plan signup mode (email|token)
+ * @property string $mode Plan signup mode (Plan::MODE_*)
* @property string $name
* @property \App\Package[] $packages
* @property datetime $promo_from
@@ -34,6 +34,11 @@
use HasTranslations;
use UuidStrKeyTrait;
+ public const MODE_EMAIL = 'email';
+ public const MODE_TOKEN = 'token';
+ public const MODE_MANDATE = 'mandate';
+
+ /** @var bool Indicates if the model should be timestamped. */
public $timestamps = false;
/** @var array<int, string> The attributes that are mass assignable */
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -54,6 +54,7 @@
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
+ * - redirectUrl: The location to goto after checkout
*
* @return array Provider payment data:
* - id: Operation identifier
@@ -80,7 +81,7 @@
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
- 'redirectUrl' => self::redirectUrl(),
+ 'redirectUrl' => $payment['redirectUrl'] ?? self::redirectUrl(),
'locale' => 'en_US',
'method' => $payment['methodId']
];
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -59,6 +59,7 @@
* - amount: Value in cents (not used)
* - currency: The operation currency
* - description: Operation desc.
+ * - redirectUrl: The location to goto after checkout
*
* @return array Provider payment/session data:
* - id: Session identifier
@@ -70,8 +71,8 @@
$request = [
'customer' => $customer_id,
- 'cancel_url' => self::redirectUrl(), // required
- 'success_url' => self::redirectUrl(), // required
+ 'cancel_url' => $payment['redirectUrl'] ?? self::redirectUrl(), // required
+ 'success_url' => $payment['redirectUrl'] ?? self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'mode' => 'setup',
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -89,6 +89,7 @@
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
+ * - redirectUrl: The location to goto after checkout
*
* @return array Provider payment data:
* - id: Operation identifier
diff --git a/src/app/Utils.php b/src/app/Utils.php
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -576,4 +576,25 @@
return floatval($rates[$targetCurrency]);
}
+
+ /**
+ * A helper to display human-readable amount of money using
+ * for specified currency and locale.
+ *
+ * @param int $amount Amount of money (in cents)
+ * @param string $currency Currency code
+ * @param string $locale Output locale
+ *
+ * @return string String representation, e.g. "9.99 CHF"
+ */
+ public static function money(int $amount, $currency, $locale = 'de_DE'): string
+ {
+ $amount = round($amount / 100, 2);
+
+ $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
+ $result = $nf->formatCurrency($amount, $currency);
+
+ // Replace non-breaking space
+ return str_replace("\xC2\xA0", " ", $result);
+ }
}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -462,12 +462,7 @@
*/
public function money(int $amount, $locale = 'de_DE')
{
- $amount = round($amount / 100, 2);
-
- $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
- $result = $nf->formatCurrency($amount, $this->currency);
- // Replace non-breaking space
- return str_replace("\xC2\xA0", " ", $result);
+ return \App\Utils::money($amount, $this->currency, $locale);
}
/**
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
@@ -33,9 +33,9 @@
return
}
- if (routerState.isLocked && to.meta.requiresAuth && !['login', 'wallet'].includes(to.name)) {
- // redirect to the wallet page
- next({ name: 'wallet' })
+ if (routerState.isLocked && to.meta.requiresAuth && !['login', 'payment-status'].includes(to.name)) {
+ // redirect to the payment-status page
+ next({ name: 'payment-status' })
return
}
@@ -149,9 +149,9 @@
if (dashboard !== false) {
this.$router.push(routerState.afterLogin || { name: response.redirect || 'dashboard' })
- } else if (routerState.isLocked && this.$route.name != 'wallet' && this.$route.meta.requiresAuth) {
+ } else if (routerState.isLocked && this.$route.meta.requiresAuth && this.$route.name != 'payment-status') {
// Always redirect locked user, here we can be after router's beforeEach handler
- this.$router.push({ name: 'wallet' })
+ this.$router.push({ name: 'payment-status' })
}
routerState.afterLogin = null
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -17,6 +17,7 @@
const DomainListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/List')
const FileInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/Info')
const FileListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/File/List')
+const PaymentStatusComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Payment/Status')
const ResourceInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/Info')
const ResourceListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Resource/List')
const RoomInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/Info')
@@ -107,6 +108,12 @@
name: 'password-reset',
component: PasswordResetComponent
},
+ {
+ path: '/payment/status',
+ name: 'payment-status',
+ component: PaymentStatusComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/profile',
name: 'profile',
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
@@ -79,6 +79,15 @@
'file-permissions-update-success' => 'File permissions updated successfully.',
'file-permissions-delete-success' => 'File permissions deleted successfully.',
+ 'payment-status-paid' => 'The payment has been completed successfully.',
+ 'payment-status-canceled' => 'The payment has been canceled.',
+ 'payment-status-failed' => 'The payment failed.',
+ 'payment-status-expired' => 'The payment expired.',
+ 'payment-status-checking' => "The payment hasn't been completed yet. Checking the status...",
+
+ 'period-year' => 'year',
+ 'period-month' => 'month',
+
'resource-update-success' => 'Resource updated successfully.',
'resource-create-success' => 'Resource created successfully.',
'resource-delete-success' => 'Resource deleted successfully.',
@@ -112,6 +121,10 @@
'search-foundxshared-folders' => ':x shared folders have been found.',
'search-foundxusers' => ':x user accounts have been found.',
+ 'signup-account-tobecreated' => 'The account is about to be created!',
+ 'signup-account-mandate' => 'Now it is required to provide your credit card details.'
+ . ' This way you agree to charge you with an appropriate amount of money according to the plan you signed up for.',
+ 'signup-account-summary' => 'You signed up for an account with a base cost of :cost per :period.',
'signup-invitations-created' => 'The invitation has been created.|:count invitations has been created.',
'signup-invitations-csv-empty' => 'Failed to find any valid email addresses in the uploaded file.',
'signup-invitations-csv-invalid-email' => 'Found an invalid email address (:email) on line :line.',
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -35,6 +35,7 @@
'signup' => "Sign Up",
'submit' => "Submit",
'suspend' => "Suspend",
+ 'tryagain' => "Try again",
'unsuspend' => "Unsuspend",
'verify' => "Verify",
],
diff --git a/src/resources/vue/Payment/Status.vue b/src/resources/vue/Payment/Status.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Payment/Status.vue
@@ -0,0 +1,64 @@
+<template>
+ <div class="container">
+ <p v-if="$root.authInfo.isLocked" id="lock-alert" class="alert alert-warning">
+ {{ $t('wallet.locked-text') }}
+ </p>
+ <div class="card">
+ <div class="card-body">
+ <div class="card-text" v-html="payment.statusMessage"></div>
+ <div class="mt-4">
+ <btn v-if="payment.tryagain" @click="tryAgain" class="btn-primary">{{ $t('btn.tryagain') }}</btn>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script>
+ export default {
+ data() {
+ return {
+ payment: {}
+ }
+ },
+ mounted() {
+ this.paymentStatus(true)
+ },
+ beforeDestroy() {
+ clearTimeout(this.timeout)
+ },
+ methods: {
+ paymentStatus(loader) {
+ axios.get('/api/v4/payments/status', { loader })
+ .then(response => {
+ this.payment = response.data
+ this.payment.tryagain = this.payment.type == 'mandate' && this.payment.status != 'paid'
+
+ if (this.payment.status == 'paid' && this.$root.authInfo.isLocked) {
+ // unlock, and redirect to the Dashboard
+ this.timeout = setTimeout(() => this.$root.unlock(), 5000)
+ } else if (['open', 'pending', 'authorized'].includes(this.payment.status)) {
+ // wait some time and check again
+ this.timeout = setTimeout(() => this.paymentStatus(false), 5000)
+ }
+ })
+ .catch(error => {
+ this.$root.errorHandler(error)
+ })
+ },
+ tryAgain() {
+ // Create the first payment and goto to the checkout page, again
+ axios.post('/api/v4/payments/mandate/reset')
+ .then(response => {
+ clearTimeout(this.timeout)
+ // TODO: We have this code in a few places now, de-duplicate!
+ if (response.data.redirectUrl) {
+ location.href = response.data.redirectUrl
+ } else if (response.data.id) {
+ // TODO: this.stripeCheckout(response.data)
+ }
+ })
+ }
+ }
+ }
+</script>
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -100,6 +100,16 @@
</form>
</div>
</div>
+
+ <div class="card d-none" id="step4">
+ <div class="card-body">
+ <div class="card-text mb-4" v-html="checkout.content"></div>
+ <form>
+ <btn class="btn-secondary me-2" @click="stepBack">{{ $t('btn.back') }}</btn>
+ <btn class="btn-primary" @click="submitStep4">{{ $t('btn.continue') }}</btn>
+ </form>
+ </div>
+ </div>
</div>
</template>
@@ -119,6 +129,7 @@
},
data() {
return {
+ checkout: {},
email: '',
first_name: '',
last_name: '',
@@ -296,23 +307,34 @@
submitStep3() {
this.$root.clearFormValidation($('#step3 form'))
- let post = {
- ...this.$root.pick(this, ['login', 'domain', 'voucher', 'plan']),
- ...this.pass
- }
+ const post = this.lastStepPostData()
- if (this.invitation) {
- post.invitation = this.invitation.id
- post.first_name = this.first_name
- post.last_name = this.last_name
+ if (this.mode == 'mandate') {
+ axios.post('/api/auth/signup/validate', post).then(response => {
+ this.checkout = response.data
+ this.displayForm(4)
+ })
} else {
- post.code = this.code
- post.short_code = this.short_code
+ axios.post('/api/auth/signup', post).then(response => {
+ // auto-login and goto dashboard
+ this.$root.loginUser(response.data)
+ })
}
+ },
+ submitStep4() {
+ const post = this.lastStepPostData()
axios.post('/api/auth/signup', post).then(response => {
- // auto-login and goto dashboard
- this.$root.loginUser(response.data)
+ // auto-login and goto to the payment checkout
+ this.$root.loginUser(response.data, false)
+
+ let checkout = response.data.checkout
+
+ if (checkout.redirectUrl) {
+ location.href = checkout.redirectUrl
+ } else if (checkout.id) {
+ // TODO: this.stripeCheckout(checkout)
+ }
})
},
// Moves the user a step back in registration form
@@ -328,7 +350,7 @@
step = 1
}
- if (this.mode == 'mandate') {
+ if (this.mode == 'mandate' && step < 3) {
step = 0
}
@@ -340,7 +362,7 @@
}
},
displayForm(step, focus) {
- [0, 1, 2, 3].filter(value => value != step).forEach(value => {
+ [0, 1, 2, 3, 4].filter(value => value != step).forEach(value => {
$('#step' + value).addClass('d-none')
})
@@ -354,6 +376,23 @@
$('#step' + step).find('input').first().focus()
}
},
+ lastStepPostData() {
+ let post = {
+ ...this.$root.pick(this, ['login', 'domain', 'voucher', 'plan']),
+ ...this.pass
+ }
+
+ if (this.invitation) {
+ post.invitation = this.invitation.id
+ post.first_name = this.first_name
+ post.last_name = this.last_name
+ } else {
+ post.code = this.code
+ post.short_code = this.short_code
+ }
+
+ return post
+ },
setDomain(response) {
if (response.domains) {
this.domains = response.domains
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
@@ -1,8 +1,5 @@
<template>
<div class="container" dusk="wallet-component">
- <p v-if="$root.authInfo.isLocked" id="lock-alert" class="alert alert-warning">
- {{ $t('wallet.locked-text') }}
- </p>
<div v-if="wallet.id" id="wallet" class="card">
<div class="card-body">
<div class="card-title">{{ $t('wallet.title') }} <span :class="wallet.balance < 0 ? 'text-danger' : 'text-success'">{{ $root.price(wallet.balance, wallet.currency) }}</span></div>
@@ -221,9 +218,6 @@
return tabs
}
},
- beforeDestroyed() {
- clearTimeout(this.refreshRequest)
- },
mounted() {
$('#wallet button').focus()
@@ -255,32 +249,21 @@
this.$refs.tabs.clickHandler('payments', () => { this.loadPayments = true })
},
methods: {
- loadMandate(refresh) {
+ loadMandate() {
const loader = '#mandate-form'
this.$root.stopLoading(loader)
- if (!this.mandate.id || this.mandate.isPending || refresh) {
- axios.get('/api/v4/payments/mandate', refresh ? {} : { loader })
- .then(response => {
- this.mandate = response.data
-
- if (this.mandate.minAmount) {
- if (this.mandate.minAmount > this.mandate.amount) {
- this.mandate.amount = this.mandate.minAmount
- }
- }
+ axios.get('/api/v4/payments/mandate', { loader })
+ .then(response => {
+ this.mandate = response.data
- if (this.$root.authInfo.isLocked) {
- if (this.mandate.isValid) {
- this.$root.unlock()
- } else {
- clearTimeout(this.refreshRequest)
- this.refreshRequest = setTimeout(() => { this.loadMandate(true) }, 10 * 1000)
- }
+ if (this.mandate.minAmount) {
+ if (this.mandate.minAmount > this.mandate.amount) {
+ this.mandate.amount = this.mandate.minAmount
}
- })
- }
+ }
+ })
},
selectPaymentMethod(method) {
this.formLock = false
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -53,6 +53,7 @@
Route::post('signup/init', [API\SignupController::class, 'init']);
Route::get('signup/invitations/{id}', [API\SignupController::class, 'invitation']);
Route::get('signup/plans', [API\SignupController::class, 'plans']);
+ Route::post('signup/validate', [API\SignupController::class, 'signupValidate']);
Route::post('signup/verify', [API\SignupController::class, 'verify']);
Route::post('signup', [API\SignupController::class, 'signup']);
}
@@ -151,9 +152,11 @@
Route::post('payments/mandate', [API\V4\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\PaymentsController::class, 'mandateDelete']);
+ Route::post('payments/mandate/reset', [API\V4\PaymentsController::class, 'mandateReset']);
Route::get('payments/methods', [API\V4\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']);
+ Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']);
Route::post('support/request', [API\V4\SupportController::class, 'request'])
->withoutMiddleware(['auth:api', 'scope:api'])
diff --git a/src/tests/Browser/Pages/Signup.php b/src/tests/Browser/Pages/PaymentStatus.php
copy from src/tests/Browser/Pages/Signup.php
copy to src/tests/Browser/Pages/PaymentStatus.php
--- a/src/tests/Browser/Pages/Signup.php
+++ b/src/tests/Browser/Pages/PaymentStatus.php
@@ -4,7 +4,7 @@
use Laravel\Dusk\Page;
-class Signup extends Page
+class PaymentStatus extends Page
{
/**
* Get the URL for the page.
@@ -13,7 +13,7 @@
*/
public function url(): string
{
- return '/signup';
+ return '/payment/status';
}
/**
@@ -25,12 +25,8 @@
*/
public function assert($browser)
{
- $browser->assertPathIs('/signup')
- ->waitUntilMissing('.app-loader')
- ->assertPresent('@step0')
- ->assertPresent('@step1')
- ->assertPresent('@step2')
- ->assertPresent('@step3');
+ $browser->waitForLocation($this->url())
+ ->waitUntilMissing('@app .app-loader');
}
/**
@@ -42,10 +38,9 @@
{
return [
'@app' => '#app',
- '@step0' => '#step0',
- '@step1' => '#step1',
- '@step2' => '#step2',
- '@step3' => '#step3',
+ '@content' => '.card .card-text',
+ '@lock-alert' => '#lock-alert',
+ '@button' => '.card button.btn-primary',
];
}
}
diff --git a/src/tests/Browser/Pages/Signup.php b/src/tests/Browser/Pages/Signup.php
--- a/src/tests/Browser/Pages/Signup.php
+++ b/src/tests/Browser/Pages/Signup.php
@@ -46,6 +46,7 @@
'@step1' => '#step1',
'@step2' => '#step2',
'@step3' => '#step3',
+ '@step4' => '#step4',
];
}
}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -13,8 +13,9 @@
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\PaymentMollie;
+use Tests\Browser\Pages\PaymentStatus;
use Tests\Browser\Pages\Signup;
-use Tests\Browser\Pages\Wallet;
use Tests\TestCaseDusk;
use Illuminate\Foundation\Testing\DatabaseMigrations;
@@ -31,7 +32,7 @@
$this->deleteTestUser('admin@user-domain-signup.com');
$this->deleteTestDomain('user-domain-signup.com');
- Plan::whereIn('mode', ['token', 'mandate'])->update(['mode' => 'email']);
+ Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]);
}
/**
@@ -44,7 +45,7 @@
$this->deleteTestDomain('user-domain-signup.com');
SignupInvitation::truncate();
- Plan::whereIn('mode', ['token', 'mandate'])->update(['mode' => 'email']);
+ Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]);
@unlink(storage_path('signup-tokens.txt'));
@@ -521,17 +522,22 @@
}
/**
- * Test signup with a mandate plan, also the wallet lock
+ * Test signup with a mandate plan, also the UI lock
+ *
+ * @group mollie
*/
public function testSignupMandate(): void
{
// Test the individual plan
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
- $plan->mode = 'mandate';
+ $plan->mode = Plan::MODE_MANDATE;
$plan->save();
$this->browse(function (Browser $browser) {
+ $config = ['paymentProvider' => 'mollie'];
$browser->visit(new Signup())
+ // Force Mollie
+ ->execScript(sprintf('Object.assign(window.config, %s)', \json_encode($config)))
->waitFor('@step0 .plan-individual button')
->click('@step0 .plan-individual button')
// Test Back button
@@ -555,30 +561,57 @@
->type('#signup_password_confirmation', '12345678')
->click('[type=submit]');
})
- ->waitUntilMissing('@step3')
- ->on(new Wallet())
- ->assertSeeIn('#lock-alert', "The account is locked")
- ->within(new Menu(), function ($browser) {
- $browser->clickMenuItem('logout');
- });
+ ->whenAvailable('@step4', function ($browser) {
+ $browser->assertSeeIn('.card-text', 'The account is about to be created!')
+ ->assertSeeIn('.card-text', 'You signed up for an account')
+ ->assertSeeIn('button.btn-primary', 'Continue')
+ ->assertSeeIn('button.btn-secondary', 'Back')
+ ->click('button.btn-secondary');
+ })
+ ->whenAvailable('@step3', function ($browser) {
+ $browser->assertValue('#signup_login', 'signuptestdusk')
+ ->click('[type=submit]');
+ })
+ ->whenAvailable('@step4', function ($browser) {
+ $browser->click('button.btn-primary');
+ })
+ ->on(new PaymentMollie())
+ ->assertSeeIn('@title', 'Auto-Payment Setup')
+ ->assertMissing('@amount')
+ ->submitPayment('open')
+ ->on(new PaymentStatus())
+ ->assertSeeIn('@lock-alert', 'The account is locked')
+ ->assertSeeIn('@content', 'Checking the status...')
+ ->assertSeeIn('@button', 'Try again');
});
$user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first();
$this->assertSame($plan->id, $user->getSetting('plan_id'));
+ $this->assertFalse($user->isActive());
- // Login again and see that the account is still locked
+ // Refresh and see that the account is still locked
$this->browse(function (Browser $browser) use ($user) {
- $browser->on(new Home())
- ->submitLogon($user->email, '12345678', false)
- ->waitForLocation('/wallet')
- ->on(new Wallet())
- ->assertSeeIn('#lock-alert', "The account is locked")
+ $browser->visit('/dashboard')
+ ->on(new PaymentStatus())
+ ->assertSeeIn('@lock-alert', 'The account is locked')
+ ->assertSeeIn('@content', 'Checking the status...');
+
+ // Mark the payment paid, and activate the user in background,
+ // expect unlock and redirect to the dashboard
+ // TODO: Move this to a separate tests file for PaymentStatus page
+ $payment = $user->wallets()->first()->payments()->first();
+ $payment->credit('Test');
+ $payment->status = \App\Payment::STATUS_PAID;
+ $payment->save();
+ $this->assertTrue($user->fresh()->isActive());
+
+ $browser->waitForLocation('/dashboard', 10)
->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
-
- // TODO: Test automatic UI unlock after creating a valid auto-payment mandate
});
+
+ // TODO: Test the 'Try again' button on /payment/status page
}
/**
@@ -587,7 +620,7 @@
public function testSignupToken(): void
{
// Test the individual plan
- Plan::where('title', 'individual')->update(['mode' => 'token']);
+ Plan::where('title', 'individual')->update(['mode' => Plan::MODE_TOKEN]);
// Register some valid tokens
$tokens = ['1234567890', 'abcdefghijk'];
@@ -640,7 +673,7 @@
$this->assertSame(null, $user->getSetting('external_email'));
// Test the group plan
- Plan::where('title', 'group')->update(['mode' => 'token']);
+ Plan::where('title', 'group')->update(['mode' => Plan::MODE_TOKEN]);
$this->browse(function (Browser $browser) use ($tokens) {
$browser->visit(new Signup())
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -803,7 +803,7 @@
'free_months' => 1,
'discount_qty' => 0,
'discount_rate' => 0,
- 'mode' => 'mandate',
+ 'mode' => Plan::MODE_MANDATE,
]);
$packages = [
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
@@ -47,8 +47,7 @@
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->settings()->whereIn('key', ['greylist_enabled', 'guam_enabled'])->delete();
- $user->status |= User::STATUS_IMAP_READY | User::STATUS_LDAP_READY;
- $user->status &= ~User::STATUS_RESTRICTED;
+ $user->status |= User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE;
$user->save();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email']);
$user->setSettings(['plan_id' => null]);
@@ -81,8 +80,7 @@
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->settings()->whereIn('key', ['greylist_enabled', 'guam_enabled'])->delete();
- $user->status |= User::STATUS_IMAP_READY | User::STATUS_LDAP_READY;
- $user->status &= ~User::STATUS_RESTRICTED;
+ $user->status |= User::STATUS_IMAP_READY | User::STATUS_LDAP_READY | User::STATUS_ACTIVE;
$user->save();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email']);
$user->setSettings(['plan_id' => null]);
@@ -1411,7 +1409,7 @@
// Ned is John's wallet controller
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
- $plan->mode = 'mandate';
+ $plan->mode = Plan::MODE_MANDATE;
$plan->save();
$wallet->owner->setSettings(['plan_id' => $plan->id]);
$ned = $this->getTestUser('ned@kolab.org');
@@ -1472,7 +1470,8 @@
$this->assertFalse($result['isLocked']);
// Test locked user
- $john->restrict();
+ $john->status &= ~User::STATUS_ACTIVE;
+ $john->save();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$john]);
$this->assertTrue($result['isLocked']);
diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php
--- a/src/tests/Unit/UtilsTest.php
+++ b/src/tests/Unit/UtilsTest.php
@@ -58,6 +58,18 @@
$this->assertSame('shared+shared/Test@test.tld', Utils::emailToLower('shared+shared/Test@Test.Tld'));
}
+ /**
+ * Test for Utils::money()
+ */
+ public function testMoney(): void
+ {
+ $this->assertSame('-0,01 CHF', Utils::money(-1, 'CHF'));
+ $this->assertSame('0,00 CHF', Utils::money(0, 'CHF'));
+ $this->assertSame('1,11 €', Utils::money(111, 'EUR'));
+ $this->assertSame('1,00 CHF', Utils::money(100, 'CHF'));
+ $this->assertSame('€0.00', Utils::money(0, 'EUR', 'en_US'));
+ }
+
/**
* Test for Utils::normalizeAddress()
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Apr 5, 5:51 AM (19 h, 3 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18832555
Default Alt Text
D4202.1775368305.diff (49 KB)
Attached To
Mode
D4202: Signup mode=mandate and account lock refinements
Attached
Detach File
Event Timeline