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 @@ -37,7 +37,8 @@ $plans = []; // Use reverse order just to have individual on left, group on right ;) - Plan::withEnvTenantContext()->orderByDesc('title')->get() + // 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); @@ -51,6 +52,7 @@ 'button' => $button, 'description' => $plan->description, 'mode' => $plan->mode ?: 'email', + 'isDomain' => $plan->hasDomain(), ]; }); @@ -58,6 +60,18 @@ } /** + * Returns list of public domains for signup. + * + * @param \Illuminate\Http\Request $request HTTP request + * + * @return \Illuminate\Http\JsonResponse JSON response + */ + public function domains(Request $request) + { + return response()->json(['status' => 'success', 'domains' => Domain::getPublicDomains()]); + } + + /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. @@ -230,8 +244,19 @@ return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Signup via invitation - if ($request->invitation) { + + $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') { + $msg = \trans('validation.exists', ['attribute' => 'plan']); + return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422); + } + } elseif ($request->invitation) { + // Signup via invitation $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { @@ -244,7 +269,6 @@ [ 'first_name' => 'max:128', 'last_name' => 'max:128', - 'voucher' => 'max:32', ] ); @@ -266,6 +290,8 @@ return $v; } + $plan = $this->getPlan(); + // Get user name/email from the verification code database $code_data = $v->getData(); @@ -274,7 +300,7 @@ 'last_name' => $code_data->last_name, ]; - if ($this->getPlan()->mode == 'token') { + if ($plan->mode == 'token') { $settings['signup_token'] = $code_data->email; } else { $settings['external_email'] = $code_data->email; @@ -292,10 +318,11 @@ } } - // Get the plan - $plan = $this->getPlan(); - $is_domain = $plan->hasDomain(); + if (empty($plan)) { + $plan = $this->getPlan(); + } + $is_domain = $plan->hasDomain(); $login = $request->login; $domain_name = $request->domain; @@ -355,7 +382,14 @@ DB::commit(); - return AuthController::logonResponse($user, $request->password); + $response = AuthController::logonResponse($user, $request->password); + + // Redirect the user to the specified page + // $data = $response->getData(true); + // $data['redirect'] = 'wallet'; + // $response->setData($data); + + return $response; } /** @@ -369,7 +403,7 @@ if (!$request->plan || !$request->plan instanceof Plan) { // Get the plan if specified and exists... - if ($request->code && $request->code->plan) { + if (($request->code instanceof SignupCode) && $request->code->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first(); } elseif ($request->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); 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 @@ -166,18 +166,25 @@ $amount = (int) ($request->amount * 100); // Validate the minimum value - // It has to be at least minimum payment amount and must cover current debt - if ( - $wallet->balance < 0 - && $wallet->balance <= Payment::MIN_AMOUNT * -1 - && $wallet->balance + $amount < 0 - ) { - return ['amount' => \trans('validation.minamountdebt')]; + // It has to be at least minimum payment amount and must cover current debt, + // and must be more than a yearly/monthly payment (according to the plan) + $min = Payment::MIN_AMOUNT; + $label = 'minamount'; + + if (($plan = $wallet->plan()) && $plan->months >= 1) { + $planCost = (int) ceil($plan->cost() * $plan->months); + if ($planCost > $min) { + $min = $planCost; + } } - if ($amount < Payment::MIN_AMOUNT) { - $min = $wallet->money(Payment::MIN_AMOUNT); - return ['amount' => \trans('validation.minamount', ['amount' => $min])]; + if ($wallet->balance < 0 && $wallet->balance < $min * -1) { + $min = $wallet->balance * -1; + $label = 'minamountdebt'; + } + + if ($amount < $min) { + return ['amount' => \trans("validation.{$label}", ['amount' => $wallet->money($min)])]; } return null; @@ -366,9 +373,10 @@ // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); - $mandate['amount'] = (int) (Payment::MIN_AMOUNT / 100); + $mandate['amount'] = $mandate['minAmount'] = (int) ceil(Payment::MIN_AMOUNT / 100); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; + $mandate['isValid'] = !empty($mandate['isValid']); foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { @@ -376,6 +384,19 @@ } } + // 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); + if ($planCost > $mandate['minAmount']) { + $mandate['minAmount'] = $planCost; + } + } + + // Unrestrict the wallet owner if mandate is valid + if (!empty($mandate['isValid']) && $wallet->owner->isRestricted()) { + $wallet->owner->unrestrict(); + } + 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 @@ -184,6 +184,8 @@ $hasBeta = in_array('beta', $skus); + $plan = $isController ? $user->wallet()->plan() : null; + $result = [ 'skus' => $skus, 'enableBeta' => in_array('beta', $skus), @@ -200,6 +202,8 @@ 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, + 'enableWalletMandates' => $isController, + 'enableWalletPayments' => $isController && (!$plan || $plan->mode != 'mandate'), 'enableCompanionapps' => $hasBeta, ]; @@ -349,6 +353,11 @@ { $response = array_merge($user->toArray(), self::objectState($user)); + $wallet = $user->wallet(); + + // IsLocked flag to lock the user to the Wallet page only + $response['isLocked'] = ($user->isRestricted() && ($plan = $wallet->plan()) && $plan->mode == 'mandate'); + // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { @@ -380,7 +389,7 @@ // Information about wallets and accounts for access checks $response['wallets'] = $user->wallets->map($map_func)->toArray(); $response['accounts'] = $user->accounts->map($map_func)->toArray(); - $response['wallet'] = $map_func($user->wallet()); + $response['wallet'] = $map_func($wallet); return $response; } diff --git a/src/app/Http/Controllers/ContentController.php b/src/app/Http/Controllers/ContentController.php --- a/src/app/Http/Controllers/ContentController.php +++ b/src/app/Http/Controllers/ContentController.php @@ -147,7 +147,7 @@ } // Unset properties that we don't need on the client side - unset($item['admin'], $item['label']); + unset($item['admin']); $menu[$idx] = $item; } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -111,14 +111,7 @@ // Remove RESTRICTED flag from the wallet owner and all users in the wallet if ($wallet->balance > $wallet->getOriginal('balance') && $wallet->owner && $wallet->owner->isRestricted()) { - $wallet->owner->unrestrict(); - - User::whereIn( - 'id', - $wallet->entitlements()->select('entitleable_id')->where('entitleable_type', User::class) - )->each(function ($user) { - $user->unrestrict(); - }); + $wallet->owner->unrestrict(true); } } } diff --git a/src/app/Package.php b/src/app/Package.php --- a/src/app/Package.php +++ b/src/app/Package.php @@ -67,7 +67,7 @@ $units = $sku->pivot->qty - $sku->units_free; if ($units < 0) { - \Log::debug("Package {$this->id} is misconfigured for more free units than qty."); + \Log::warning("Package {$this->id} is misconfigured for more free units than qty."); $units = 0; } diff --git a/src/app/Payment.php b/src/app/Payment.php --- a/src/app/Payment.php +++ b/src/app/Payment.php @@ -106,7 +106,15 @@ */ public function credit($method): void { - // TODO: Possibly we should sanity check that payment is paid, and not negative? + if (empty($this->wallet)) { + throw new \Exception("Cannot credit a payment not assigned to a wallet"); + } + + if ($this->credit_amount < 0) { + throw new \Exception("Cannot credit a payment with negative amount"); + } + + // TODO: Possibly we should sanity check that payment is paid? // TODO: Localization? $description = $this->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$this->id} using {$method}"; @@ -117,6 +125,11 @@ if ($this->wallet->balance >= 0) { $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); + } } /** diff --git a/src/app/Plan.php b/src/app/Plan.php --- a/src/app/Plan.php +++ b/src/app/Plan.php @@ -49,6 +49,8 @@ 'discount_qty', // the rate of the discount for this plan 'discount_rate', + // minimum number of months this plan is for + 'months', // number of free months (trial) 'free_months', ]; @@ -59,6 +61,7 @@ 'promo_to' => 'datetime:Y-m-d H:i:s', 'discount_qty' => 'integer', 'discount_rate' => 'integer', + 'months' => 'integer', 'free_months' => 'integer' ]; 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 @@ -360,7 +360,7 @@ if ($mollie_payment->isPaid()) { // The payment is paid. Update the balance, and notify the user - if ($payment->status != Payment::STATUS_PAID && $payment->amount > 0) { + if ($payment->status != Payment::STATUS_PAID && $payment->amount >= 0) { $credit = true; $notify = $payment->type == Payment::TYPE_RECURRING; } 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 @@ -369,6 +369,9 @@ $payment->wallet->setSetting('stripe_mandate_id', $intent->id); $threshold = intval((float) $payment->wallet->getSetting('mandate_balance') * 100); + // Call credit() so wallet/account state is updated + $this->creditPayment($payment, $intent); + // Top-up the wallet if balance is below the threshold if ($payment->wallet->balance < $threshold && $payment->status != Payment::STATUS_PAID) { \App\Jobs\WalletCharge::dispatch($payment->wallet); diff --git a/src/app/User.php b/src/app/User.php --- a/src/app/User.php +++ b/src/app/User.php @@ -557,16 +557,27 @@ /** * Un-restrict this user. * + * @param bool $deep Unrestrict also all users in the account + * * @return void */ - public function unrestrict(): void + public function unrestrict(bool $deep = false): void { - if (!$this->isRestricted()) { - return; + if ($this->isRestricted()) { + $this->status ^= User::STATUS_RESTRICTED; + $this->save(); + } + + // Remove the flag from all users in the user's wallets + if ($deep) { + $this->wallets->each(function ($wallet) { + User::whereIn('id', $wallet->entitlements()->select('entitleable_id') + ->where('entitleable_type', User::class)) + ->each(function ($user) { + $user->unrestrict(); + }); + }); } - - $this->status ^= User::STATUS_RESTRICTED; - $this->save(); } /** diff --git a/src/database/migrations/2023_02_17_100000_vat_rates_table.php b/src/database/migrations/2023_02_17_100000_vat_rates_table.php --- a/src/database/migrations/2023_02_17_100000_vat_rates_table.php +++ b/src/database/migrations/2023_02_17_100000_vat_rates_table.php @@ -78,6 +78,7 @@ Schema::table( 'payments', function (Blueprint $table) { + $table->dropForeign(['vat_rate_id']); $table->dropColumn('vat_rate_id'); $table->dropColumn('credit_amount'); } diff --git a/src/database/migrations/2023_03_01_100000_plans_months.php b/src/database/migrations/2023_03_01_100000_plans_months.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_03_01_100000_plans_months.php @@ -0,0 +1,38 @@ +tinyInteger('months')->unsigned()->default(1); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'plans', + function (Blueprint $table) { + $table->dropColumn('months'); + } + ); + } +}; diff --git a/src/resources/build/before.php b/src/resources/build/before.php --- a/src/resources/build/before.php +++ b/src/resources/build/before.php @@ -37,6 +37,20 @@ } } +foreach (glob("{$rootDir}/resources/themes/*/lang/*/ui.php") as $file) { + $content = include $file; + + if (is_array($content)) { + preg_match('|([a-zA-Z]+)/lang/([a-z]+)/ui\.php$|', $file, $matches); + + $theme = $matches[1]; + $file = "{$rootDir}/resources/build/js/{$theme}-{$matches[2]}.json"; + $opts = JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_UNICODE; + + file_put_contents($file, json_encode($content, $opts)); + } +} + echo "OK\n"; // Move some theme-specific resources from resources/themes/ to public/themes/ @@ -54,14 +68,19 @@ mkdir("{$rootDir}/public/themes/{$theme}"); } - if (!file_exists("{$rootDir}/public/themes/{$theme}/images")) { - mkdir("{$rootDir}/public/themes/{$theme}/images"); - } + // TODO: Public dirs (glob patterns) should be in theme's meta.json + + foreach (['images', 'fonts'] as $subDir) { + if (file_exists("{$rootDir}/resources/themes/{$theme}/{$subDir}")) { + if (!file_exists("{$rootDir}/public/themes/{$theme}/{$subDir}")) { + mkdir("{$rootDir}/public/themes/{$theme}/{$subDir}"); + } - foreach (glob("{$file}/images/*") as $file) { - $path = explode('/', $file); - $image = $path[count($path)-1]; - copy($file, "{$rootDir}/public/themes/{$theme}/images/{$image}"); + foreach (glob("{$rootDir}/resources/themes/{$theme}/{$subDir}/*") as $file) { + $filename = pathinfo($file, PATHINFO_BASENAME); + copy($file, "{$rootDir}/public/themes/{$theme}/{$subDir}/{$filename}"); + } + } } } 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 @@ -14,7 +14,8 @@ const routerState = { afterLogin: null, - isLoggedIn: !!localStorage.getItem('token') + isLoggedIn: !!localStorage.getItem('token'), + isLocked: false } let loadingRoute @@ -29,7 +30,12 @@ // redirect to login page next({ name: 'login' }) + return + } + if (routerState.isLocked && to.meta.requiresAuth && !['login', 'wallet'].includes(to.name)) { + // redirect to the wallet page + next({ name: 'wallet' }) return } @@ -139,8 +145,13 @@ this.authInfo = response } + routerState.isLocked = this.authInfo && this.authInfo.isLocked + if (dashboard !== false) { - this.$router.push(routerState.afterLogin || { name: 'dashboard' }) + this.$router.push(routerState.afterLogin || { name: response.redirect || 'dashboard' }) + } else if (routerState.isLocked && this.$route.name != 'wallet' && this.$route.meta.requiresAuth) { + // Always redirect locked user, here we can be after router's beforeEach handler + this.$router.push({ name: 'wallet' }) } routerState.afterLogin = null @@ -321,6 +332,10 @@ return this.$t('status.active') }, + unlock() { + routerState.isLocked = this.authInfo.isLocked = false + this.$router.push({ name: 'dashboard' }) + }, // Append some wallet properties to the object userWalletProps(object) { let wallet = this.authInfo.accounts[0] diff --git a/src/resources/js/locale.js b/src/resources/js/locale.js --- a/src/resources/js/locale.js +++ b/src/resources/js/locale.js @@ -17,6 +17,7 @@ let currentLanguage const loadedLanguages = ['en'] // our default language that is preloaded +const loadedThemeLanguages = [] const setI18nLanguage = (lang) => { i18n.locale = lang @@ -32,7 +33,25 @@ const age = 10 * 60 * 60 * 24 * 365 document.cookie = 'language=' + lang + '; max-age=' + age + '; path=/; secure' - return lang + // Load additional localization from the theme + return loadThemeLang(lang) +} + +const loadThemeLang = (lang) => { + if (loadedThemeLanguages.includes(lang)) { + return + } + + const theme = window.config['app.theme'] + + if (theme && theme != 'default') { + return import(/* webpackChunkName: "locale/[request]" */ `../build/js/${theme}-${lang}.json`) + .then(messages => { + i18n.mergeLocaleMessage(lang, messages.default) + loadedThemeLanguages.push(lang) + }) + .catch(error => { /* ignore errors */ }) + } } export const getLang = () => { @@ -61,6 +80,6 @@ .then(messages => { i18n.setLocaleMessage(lang, messages.default) loadedLanguages.push(lang) - return setI18nLanguage(lang) + return Promise.resolve(setI18nLanguage(lang)) }) } 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 @@ -539,6 +539,7 @@ . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", + 'locked-text' => "The account is locked until you set up auto-payment successfully.", 'month' => "month", 'noperm' => "Only account owners can access a wallet.", 'norefund' => "The money in your wallet is non-refundable.", 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 @@ -2,11 +2,11 @@
-
+
- +
@@ -65,7 +65,7 @@
-

{{ $t('signup.title') }} - {{ $t('nav.step', { i: steps, n: steps }) }}

+

{{ $t('signup.title') }} - {{ $t('nav.step', { i: steps, n: steps }) }}

{{ $t('signup.step3', { app: $root.appName }) }}

@@ -144,7 +144,15 @@ }, computed: { steps() { - return this.mode == 'token' ? 2 : 3 + switch (this.mode) { + case 'token': + return 2 + case 'mandate': + return 1 + case 'email': + default: + return 3 + } } }, mounted() { @@ -197,7 +205,24 @@ if (plan) { this.plan = title this.mode = plan.mode - this.displayForm(1, true) + this.is_domain = plan.isDomain + this.domain = '' + + let step = 1 + + if (plan.mode == 'mandate') { + step = 3 + if (!plan.isDomain || !this.domains.length) { + axios.get('/api/auth/signup/domains') + .then(response => { + this.displayForm(step, true) + this.setDomain(response.data) + }) + return + } + } + + this.displayForm(step, true) } }, step0(plan) { @@ -272,13 +297,12 @@ this.$root.clearFormValidation($('#step3 form')) let post = { - ...this.$root.pick(this, ['login', 'domain', 'voucher']), + ...this.$root.pick(this, ['login', 'domain', 'voucher', 'plan']), ...this.pass } if (this.invitation) { post.invitation = this.invitation.id - post.plan = this.plan post.first_name = this.first_name post.last_name = this.last_name } else { @@ -304,6 +328,10 @@ step = 1 } + if (this.mode == 'mandate') { + step = 0 + } + $('#step' + step).removeClass('d-none').find('input').first().focus() if (!step) { @@ -331,7 +359,14 @@ this.domains = response.domains } - this.domain = response.domain || window.config['app.domain'] + this.domain = response.domain + + if (!this.domain) { + this.domain = window.config['app.domain'] + if (this.domains.length && !this.domains.includes(this.domain)) { + this.domain = this.domains[0] + } + } } } } 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,5 +1,8 @@