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,15 +411,68 @@ $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 * * @returns \App\Plan Plan object selected for current 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 @@ -140,6 +140,36 @@ } /** + * 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. * * @param \Illuminate\Http\Request $request The API 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; } @@ -191,6 +221,39 @@ } /** + * 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. * * @param \Illuminate\Http\Request $request The API request. @@ -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') @@ -108,6 +109,12 @@ component: PasswordResetComponent }, { + path: '/payment/status', + name: 'payment-status', + component: PaymentStatusComponent, + meta: { requiresAuth: true } + }, + { path: '/profile', name: 'profile', component: UserProfileComponent, 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/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -371,8 +371,8 @@ $json = $response->json(); - $this->assertSame((int) ceil(Payment::MIN_AMOUNT / 100), $json['amount']); - $this->assertSame((int) ceil(($plan->cost() * $plan->months) / 100), $json['minAmount']); + $this->assertEquals(round(Payment::MIN_AMOUNT / 100, 2), $json['amount']); + $this->assertEquals(round($plan->cost() * $plan->months / 100, 2), $json['minAmount']); // TODO: Test more cases // TODO: Test user unrestricting if mandate is valid 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 @@ -59,6 +59,18 @@ } /** + * 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() */ public function testNormalizeAddress(): void