Page MenuHomePhorge

No OneTemporary

Size
498 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
index 9a6193ce..46002122 100644
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -1,474 +1,554 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Jobs\SignupVerificationEmail;
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;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\SignupCode;
use App\SignupInvitation;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
/**
* Signup process API
*/
class SignupController extends Controller
{
/**
* Returns plans definitions for signup.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
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]);
}
/**
* 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.
* Returns the verification code.
*
* @param \Illuminate\Http\Request $request HTTP request
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function init(Request $request)
{
$rules = [
'first_name' => 'max:128',
'last_name' => 'max:128',
'voucher' => 'max:32',
];
$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()];
}
// Check required fields, validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()->toArray()], 422);
}
// 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,
'voucher' => $request->voucher,
]);
$response = [
'status' => 'success',
'code' => $code->code,
'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();
$response['short_code'] = $code->short_code;
$response['is_domain'] = $has_domain;
$response['domains'] = $has_domain ? [] : Domain::getPublicDomains();
} else {
// External email verification, send an email message
SignupVerificationEmail::dispatch($code);
}
return response()->json($response);
}
/**
* Returns signup invitation information.
*
* @param string $id Signup invitation identifier
*
* @return \Illuminate\Http\JsonResponse|void
*/
public function invitation($id)
{
$invitation = SignupInvitation::withEnvTenantContext()->find($id);
if (empty($invitation) || $invitation->isCompleted()) {
return $this->errorResponse(404);
}
$has_domain = $this->getPlan()->hasDomain();
$result = [
'id' => $id,
'is_domain' => $has_domain,
'domains' => $has_domain ? [] : Domain::getPublicDomains(),
];
return response()->json($result);
}
/**
* Validation of the verification code.
*
* @param \Illuminate\Http\Request $request HTTP request
* @param bool $update Update the signup code record
*
* @return \Illuminate\Http\JsonResponse JSON response
*/
public function verify(Request $request, $update = true)
{
// Validate the request args
$v = Validator::make(
$request->all(),
[
'code' => 'required',
'short_code' => 'required',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
// Validate the verification code
$code = SignupCode::find($request->code);
if (
empty($code)
|| $code->isExpired()
|| Str::upper($request->short_code) !== Str::upper($code->short_code)
) {
$errors = ['short_code' => "The code is invalid or expired."];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// For signup last-step mode remember the code object, so we can delete it
// with single SQL query (->delete()) instead of two
$request->code = $code;
if ($update) {
$code->verify_ip_address = $request->ip();
$code->save();
}
$has_domain = $this->getPlan()->hasDomain();
// Return user name and email/phone/voucher from the codes database,
// domains list for selection and "plan type" flag
return response()->json([
'status' => 'success',
'email' => $code->email,
'first_name' => $code->first_name,
'last_name' => $code->last_name,
'voucher' => $code->voucher,
'is_domain' => $has_domain,
'domains' => $has_domain ? [] : Domain::getPublicDomains(),
]);
}
/**
- * 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(
$request->all(),
[
'login' => 'required|min:2',
'password' => ['required', 'confirmed', new Password()],
'domain' => 'required',
'voucher' => 'max:32',
]
);
if ($v->fails()) {
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);
}
} elseif ($request->invitation) {
// Signup via invitation
$invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation);
if (empty($invitation) || $invitation->isCompleted()) {
return $this->errorResponse(404);
}
// Check required fields
$v = Validator::make(
$request->all(),
[
'first_name' => 'max:128',
'last_name' => 'max:128',
]
);
$errors = $v->fails() ? $v->errors()->toArray() : [];
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$settings = [
'external_email' => $invitation->email,
'first_name' => $request->first_name,
'last_name' => $request->last_name,
];
} else {
// Validate verification codes (again)
$v = $this->verify($request, false);
if ($v->status() !== 200) {
return $v;
}
$plan = $this->getPlan();
// Get user name/email from the verification code database
$code_data = $v->getData();
$settings = [
'first_name' => $code_data->first_name,
'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;
}
}
// Find the voucher discount
if ($request->voucher) {
$discount = Discount::where('code', \strtoupper($request->voucher))
->where('active', true)->first();
if (!$discount) {
$errors = ['voucher' => \trans('validation.voucherinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
}
if (empty($plan)) {
$plan = $this->getPlan();
}
$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();
// Create domain record
if ($is_domain) {
$domain = Domain::create([
'namespace' => $domain_name,
'type' => Domain::TYPE_EXTERNAL,
]);
}
// Create user record
$user = User::create([
'email' => $login . '@' . $domain_name,
'password' => $request->password,
'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
if ($request->code) {
$request->code->user_id = $user->id;
$request->code->submit_ip_address = $request->ip();
$request->code->deleted_at = \now();
$request->code->timestamps = false;
$request->code->save();
}
DB::commit();
$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
*/
protected function getPlan()
{
$request = request();
if (!$request->plan || !$request->plan instanceof Plan) {
// Get the plan if specified and exists...
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();
}
// ...otherwise use the default plan
if (empty($plan)) {
// TODO: Get default plan title from config
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
}
$request->plan = $plan;
}
return $request->plan;
}
/**
* Login (kolab identity) validation
*
* @param string $login Login (local part of an email address)
* @param string $domain Domain name
* @param bool $external Enables additional checks for domain part
*
* @return array Error messages on validation error
*/
protected static function validateLogin($login, $domain, $external = false): ?array
{
// Validate login part alone
$v = Validator::make(
['login' => $login],
['login' => ['required', 'string', new UserEmailLocal($external)]]
);
if ($v->fails()) {
return ['login' => $v->errors()->toArray()['login'][0]];
}
$domains = $external ? null : Domain::getPublicDomains();
// Validate the domain
$v = Validator::make(
['domain' => $domain],
['domain' => ['required', 'string', new UserEmailDomain($domains)]]
);
if ($v->fails()) {
return ['domain' => $v->errors()->toArray()['domain'][0]];
}
$domain = Str::lower($domain);
// Check if domain is already registered with us
if ($external) {
if (Domain::withTrashed()->where('namespace', $domain)->exists()) {
return ['domain' => \trans('validation.domainexists')];
}
}
// Check if user with specified login already exists
$email = $login . '@' . $domain;
if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) {
return ['login' => \trans('validation.loginexists')];
}
return null;
}
}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index 9eab311b..08ca1393 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,540 +1,606 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Tenant;
use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* Get the auto-payment mandate info.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandate()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$mandate = self::walletMandate($wallet);
return response()->json($mandate);
}
/**
* Create a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateCreate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
]);
$mandate = [
'currency' => $wallet->currency,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup',
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
// we'll top-up the wallet with the configured auto-payment amount
if ($wallet->balance < intval($request->balance * 100)) {
$mandate['amount'] = intval($request->amount * 100);
self::addTax($wallet, $mandate);
}
$provider = PaymentProvider::factory($wallet);
$result = $provider->createMandate($wallet, $mandate);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Revoke the auto-payment mandate.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateDelete()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$provider = PaymentProvider::factory($wallet);
$provider->deleteMandate($wallet);
$wallet->setSetting('mandate_disabled', null);
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
]);
}
/**
* Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateUpdate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
// Re-enable the mandate to give it a chance to charge again
// after it has been disabled (e.g. because the mandate amount was too small)
'mandate_disabled' => null,
]);
// Trigger auto-payment if the balance is below the threshold
if ($wallet->balance < intval($request->balance * 100)) {
\App\Jobs\WalletCharge::dispatch($wallet);
}
$result = self::walletMandate($wallet);
$result['status'] = 'success';
$result['message'] = \trans('app.mandate-update-success');
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.
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\Wallet $wallet The wallet
*
* @return array|null List of errors on error or Null on success
*/
protected static function mandateValidate(Request $request, Wallet $wallet)
{
$rules = [
'amount' => 'required|numeric',
'balance' => 'required|numeric|min:0',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return $v->errors()->toArray();
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
// 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);
+ $planCost = $plan->cost() * $plan->months;
if ($planCost > $min) {
$min = $planCost;
}
}
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;
}
+ /**
+ * 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.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$rules = [
'amount' => 'required|numeric',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
if ($amount < Payment::MIN_AMOUNT) {
$min = $wallet->money(Payment::MIN_AMOUNT);
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$currency = $request->currency;
$request = [
'type' => Payment::TYPE_ONEOFF,
'currency' => $currency,
'amount' => $amount,
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment',
];
self::addTax($wallet, $request);
$provider = PaymentProvider::factory($wallet, $currency);
$result = $provider->payment($wallet, $request);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Delete a pending payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
// TODO currently unused
// public function cancel(Request $request)
// {
// $user = $this->guard()->user();
// // TODO: Wallet selection
// $wallet = $user->wallets()->first();
// $paymentId = $request->payment;
// $user_owns_payment = Payment::where('id', $paymentId)
// ->where('wallet_id', $wallet->id)
// ->exists();
// if (!$user_owns_payment) {
// return $this->errorResponse(404);
// }
// $provider = PaymentProvider::factory($wallet);
// if ($provider->cancel($wallet, $paymentId)) {
// $result = ['status' => 'success'];
// return response()->json($result);
// }
// return $this->errorResponse(404);
// }
/**
* Update payment status (and balance).
*
* @param string $provider Provider name
*
* @return \Illuminate\Http\Response The response
*/
public function webhook($provider)
{
$code = 200;
if ($provider = PaymentProvider::factory($provider)) {
$code = $provider->webhook();
}
return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
* Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
*
* @return bool True if the payment has been initialized
*/
public static function topUpWallet(Wallet $wallet): bool
{
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
\Log::debug("Requested top-up for wallet {$wallet->id}");
if (!empty($settings['mandate_disabled'])) {
\Log::debug("Top-up for wallet {$wallet->id}: mandate disabled");
return false;
}
$min_balance = (int) (floatval($settings['mandate_balance']) * 100);
$amount = (int) (floatval($settings['mandate_amount']) * 100);
// The wallet balance is greater than the auto-payment threshold
if ($wallet->balance >= $min_balance) {
// Do nothing
return false;
}
$provider = PaymentProvider::factory($wallet);
$mandate = (array) $provider->getMandate($wallet);
if (empty($mandate['isValid'])) {
\Log::debug("Top-up for wallet {$wallet->id}: mandate invalid");
return false;
}
// The defined top-up amount is not enough
// Disable auto-payment and notify the user
if ($wallet->balance + $amount < 0) {
// Disable (not remove) the mandate
$wallet->setSetting('mandate_disabled', 1);
\App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
return false;
}
$request = [
'type' => Payment::TYPE_RECURRING,
'currency' => $wallet->currency,
'amount' => $amount,
'methodId' => PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment',
];
self::addTax($wallet, $request);
$result = $provider->payment($wallet, $request);
return !empty($result);
}
/**
* Returns auto-payment mandate info for the specified wallet
*
* @param \App\Wallet $wallet A wallet object
*
* @return array A mandate metadata
*/
public static function walletMandate(Wallet $wallet): array
{
$provider = PaymentProvider::factory($wallet);
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
$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) {
$mandate[$key] = $value;
}
}
// 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;
}
}
// Unrestrict the wallet owner if mandate is valid
if (!empty($mandate['isValid']) && $wallet->owner->isRestricted()) {
$wallet->owner->unrestrict();
}
return $mandate;
}
/**
* List supported payment methods.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function paymentMethods(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$methods = PaymentProvider::paymentMethods($wallet, $request->type);
\Log::debug("Provider methods" . var_export(json_encode($methods), true));
return response()->json($methods);
}
/**
* Check for pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function hasPayments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$exists = Payment::where('wallet_id', $wallet->id)
->where('type', Payment::TYPE_ONEOFF)
->whereIn('status', [
Payment::STATUS_OPEN,
Payment::STATUS_PENDING,
Payment::STATUS_AUTHORIZED
])
->exists();
return response()->json([
'status' => 'success',
'hasPending' => $exists
]);
}
/**
* List pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function payments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$result = Payment::where('wallet_id', $wallet->id)
->where('type', Payment::TYPE_ONEOFF)
->whereIn('status', [
Payment::STATUS_OPEN,
Payment::STATUS_PENDING,
Payment::STATUS_AUTHORIZED
])
->orderBy('created_at', 'desc')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
$result = $result->map(function ($item) use ($wallet) {
$provider = PaymentProvider::factory($item->provider);
$payment = $provider->getPayment($item->id);
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->description,
'amount' => $item->amount,
'currency' => $wallet->currency,
// note: $item->currency/$item->currency_amount might be different
'status' => $item->status,
'isCancelable' => $payment['isCancelable'],
'checkoutUrl' => $payment['checkoutUrl']
];
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
/**
* 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
{
$request['vat_rate_id'] = null;
$request['credit_amount'] = $request['amount'];
if ($rate = $wallet->vatRate()) {
$request['vat_rate_id'] = $rate->id;
switch (\config('app.vat.mode')) {
case 1:
// In this mode tax is added on top of the payment. The amount
// to pay grows, but we keep wallet balance without tax.
$request['amount'] = $request['amount'] + round($request['amount'] * $rate->rate / 100);
break;
default:
// In this mode tax is "swallowed" by the vendor. The payment
// amount does not change
break;
}
}
}
}
diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php
index d63917de..71fbc606 100644
--- a/src/app/Http/Controllers/API/V4/UsersController.php
+++ b/src/app/Http/Controllers/API/V4/UsersController.php
@@ -1,709 +1,710 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\RelationController;
use App\Domain;
+use App\Plan;
use App\Rules\Password;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\Sku;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class UsersController extends RelationController
{
/** @const array List of user setting keys available for modification in UI */
public const USER_SETTINGS = [
'billing_address',
'country',
'currency',
'external_email',
'first_name',
'last_name',
'organization',
'phone',
];
/**
* On user create it is filled with a user or group object to force-delete
* before the creation of a new user record is possible.
*
* @var \App\User|\App\Group|null
*/
protected $deleteBeforeCreate;
/** @var string Resource localization label */
protected $label = 'user';
/** @var string Resource model name */
protected $model = User::class;
/** @var array Common object properties in the API response */
protected $objectProps = ['email'];
/** @var ?\App\VerificationCode Password reset code to activate on user create/update */
protected $passCode;
/**
* Listing of users.
*
* The user-entitlements billed to the current user wallet(s)
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
$user = $this->guard()->user();
$search = trim(request()->input('search'));
$page = intval(request()->input('page')) ?: 1;
$pageSize = 20;
$hasMore = false;
$result = $user->users();
// Search by user email, alias or name
if (strlen($search) > 0) {
// thanks to cloning we skip some extra queries in $user->users()
$allUsers1 = clone $result;
$allUsers2 = clone $result;
$result->whereLike('email', $search)
->union(
$allUsers1->join('user_aliases', 'users.id', '=', 'user_aliases.user_id')
->whereLike('alias', $search)
)
->union(
$allUsers2->join('user_settings', 'users.id', '=', 'user_settings.user_id')
->whereLike('value', $search)
->whereIn('key', ['first_name', 'last_name'])
);
}
$result = $result->orderBy('email')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
// Process the result
$result = $result->map(
function ($user) {
return $this->objectToClient($user);
}
);
$result = [
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
];
return response()->json($result);
}
/**
* Display information on the user account specified by $id.
*
* @param string $id The account to show information for.
*
* @return \Illuminate\Http\JsonResponse
*/
public function show($id)
{
$user = User::find($id);
if (!$this->checkTenant($user)) {
return $this->errorResponse(404);
}
if (!$this->guard()->user()->canRead($user)) {
return $this->errorResponse(403);
}
$response = $this->userResponse($user);
$response['skus'] = \App\Entitlement::objectEntitlementsSummary($user);
$response['config'] = $user->getConfig();
$response['aliases'] = $user->aliases()->pluck('alias')->all();
$code = $user->verificationcodes()->where('active', true)
->where('expires_at', '>', \Carbon\Carbon::now())
->first();
if ($code) {
$response['passwordLinkCode'] = $code->short_code . '-' . $code->code;
}
return response()->json($response);
}
/**
* User status (extended) information
*
* @param \App\User $user User object
*
* @return array Status information
*/
public static function statusInfo($user): array
{
$process = self::processStateInfo(
$user,
[
'user-new' => true,
'user-ldap-ready' => $user->isLdapReady(),
'user-imap-ready' => $user->isImapReady(),
]
);
// Check if the user is a controller of his wallet
$isController = $user->canDelete($user);
$isDegraded = $user->isDegraded();
$hasMeet = !$isDegraded && Sku::withObjectTenantContext($user)->where('title', 'room')->exists();
$hasCustomDomain = $user->wallet()->entitlements()
->where('entitleable_type', Domain::class)
->count() > 0;
// Get user's entitlements titles
$skus = $user->entitlements()->select('skus.title')
->join('skus', 'skus.id', '=', 'entitlements.sku_id')
->get()
->pluck('title')
->sort()
->unique()
->values()
->all();
$hasBeta = in_array('beta', $skus);
$plan = $isController ? $user->wallet()->plan() : null;
$result = [
'skus' => $skus,
'enableBeta' => in_array('beta', $skus),
// TODO: This will change when we enable all users to create domains
'enableDomains' => $isController && $hasCustomDomain,
// TODO: Make 'enableDistlists' working for wallet controllers that aren't account owners
'enableDistlists' => $isController && $hasCustomDomain && $hasBeta,
'enableFiles' => !$isDegraded && $hasBeta && \config('app.with_files'),
// TODO: Make 'enableFolders' working for wallet controllers that aren't account owners
'enableFolders' => $isController && $hasCustomDomain && $hasBeta,
// TODO: Make 'enableResources' working for wallet controllers that aren't account owners
'enableResources' => $isController && $hasCustomDomain && $hasBeta,
'enableRooms' => $hasMeet,
'enableSettings' => $isController,
'enableUsers' => $isController,
'enableWallets' => $isController,
'enableWalletMandates' => $isController,
- 'enableWalletPayments' => $isController && (!$plan || $plan->mode != 'mandate'),
+ 'enableWalletPayments' => $isController && (!$plan || $plan->mode != Plan::MODE_MANDATE),
'enableCompanionapps' => $hasBeta,
];
return array_merge($process, $result);
}
/**
* Create a new user record.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$current_user = $this->guard()->user();
$owner = $current_user->walletOwner();
if ($owner->id != $current_user->id) {
return $this->errorResponse(403);
}
$this->deleteBeforeCreate = null;
if ($error_response = $this->validateUserRequest($request, null, $settings)) {
return $error_response;
}
if (empty($request->package) || !($package = \App\Package::withEnvTenantContext()->find($request->package))) {
$errors = ['package' => \trans('validation.packagerequired')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
if ($package->isDomain()) {
$errors = ['package' => \trans('validation.packageinvalid')];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
DB::beginTransaction();
// @phpstan-ignore-next-line
if ($this->deleteBeforeCreate) {
$this->deleteBeforeCreate->forceDelete();
}
// Create user record
$user = User::create([
'email' => $request->email,
'password' => $request->password,
'status' => $owner->isRestricted() ? User::STATUS_RESTRICTED : 0,
]);
$this->activatePassCode($user);
$owner->assignPackage($package, $user);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
return response()->json([
'status' => 'success',
'message' => \trans('app.user-create-success'),
]);
}
/**
* Update user data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id User identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$user = User::withEnvTenantContext()->find($id);
if (empty($user)) {
return $this->errorResponse(404);
}
$current_user = $this->guard()->user();
// TODO: Decide what attributes a user can change on his own profile
if (!$current_user->canUpdate($user)) {
return $this->errorResponse(403);
}
if ($error_response = $this->validateUserRequest($request, $user, $settings)) {
return $error_response;
}
// Entitlements, only controller can do that
if ($request->skus !== null && !$current_user->canDelete($user)) {
return $this->errorResponse(422, "You have no permission to change entitlements");
}
DB::beginTransaction();
SkusController::updateEntitlements($user, $request->skus);
if (!empty($settings)) {
$user->setSettings($settings);
}
if (!empty($request->password)) {
$user->password = $request->password;
$user->save();
}
$this->activatePassCode($user);
if (isset($request->aliases)) {
$user->setAliases($request->aliases);
}
DB::commit();
$response = [
'status' => 'success',
'message' => \trans('app.user-update-success'),
];
// For self-update refresh the statusInfo in the UI
if ($user->id == $current_user->id) {
$response['statusInfo'] = self::statusInfo($user);
}
return response()->json($response);
}
/**
* Create a response data array for specified user.
*
* @param \App\User $user User object
*
* @return array Response data
*/
public static function userResponse(User $user): array
{
$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');
+ $response['isLocked'] = (!$user->isActive() && ($plan = $wallet->plan()) && $plan->mode == Plan::MODE_MANDATE);
// Settings
$response['settings'] = [];
foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) {
$response['settings'][$item->key] = $item->value;
}
// Status info
$response['statusInfo'] = self::statusInfo($user);
// Add more info to the wallet object output
$map_func = function ($wallet) use ($user) {
$result = $wallet->toArray();
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
if ($wallet->user_id != $user->id) {
$result['user_email'] = $wallet->owner->email;
}
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
return $result;
};
// 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($wallet);
return $response;
}
/**
* Prepare user statuses for the UI
*
* @param \App\User $user User object
*
* @return array Statuses array
*/
protected static function objectState($user): array
{
$state = parent::objectState($user);
$state['isAccountDegraded'] = $user->isDegraded(true);
return $state;
}
/**
* Validate user input
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\User|null $user User identifier
* @param array $settings User settings (from the request)
*
* @return \Illuminate\Http\JsonResponse|null The error response on error
*/
protected function validateUserRequest(Request $request, $user, &$settings = [])
{
$rules = [
'external_email' => 'nullable|email',
'phone' => 'string|nullable|max:64|regex:/^[0-9+() -]+$/',
'first_name' => 'string|nullable|max:128',
'last_name' => 'string|nullable|max:128',
'organization' => 'string|nullable|max:512',
'billing_address' => 'string|nullable|max:1024',
'country' => 'string|nullable|alpha|size:2',
'currency' => 'string|nullable|alpha|size:3',
'aliases' => 'array|nullable',
];
$controller = ($user ?: $this->guard()->user())->walletOwner();
// Handle generated password reset code
if ($code = $request->input('passwordLinkCode')) {
// Accept <short-code>-<code> input
if (strpos($code, '-')) {
$code = explode('-', $code)[1];
}
$this->passCode = $this->guard()->user()->verificationcodes()
->where('code', $code)->where('active', false)->first();
// Generate a password for a new user with password reset link
// FIXME: Should/can we have a user with no password set?
if ($this->passCode && empty($user)) {
$request->password = $request->password_confirmation = Str::random(16);
$ignorePassword = true;
}
}
if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) {
if (empty($ignorePassword)) {
$rules['password'] = ['required', 'confirmed', new Password($controller)];
}
}
$errors = [];
// Validate input
$v = Validator::make($request->all(), $rules);
if ($v->fails()) {
$errors = $v->errors()->toArray();
}
// For new user validate email address
if (empty($user)) {
$email = $request->email;
if (empty($email)) {
$errors['email'] = \trans('validation.required', ['attribute' => 'email']);
} elseif ($error = self::validateEmail($email, $controller, $this->deleteBeforeCreate)) {
$errors['email'] = $error;
}
}
// Validate aliases input
if (isset($request->aliases)) {
$aliases = [];
$existing_aliases = $user ? $user->aliases()->get()->pluck('alias')->toArray() : [];
foreach ($request->aliases as $idx => $alias) {
if (is_string($alias) && !empty($alias)) {
// Alias cannot be the same as the email address (new user)
if (!empty($email) && Str::lower($alias) == Str::lower($email)) {
continue;
}
// validate new aliases
if (
!in_array($alias, $existing_aliases)
&& ($error = self::validateAlias($alias, $controller))
) {
if (!isset($errors['aliases'])) {
$errors['aliases'] = [];
}
$errors['aliases'][$idx] = $error;
continue;
}
$aliases[] = $alias;
}
}
$request->aliases = $aliases;
}
if (!empty($errors)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
// Update user settings
$settings = $request->only(array_keys($rules));
unset($settings['password'], $settings['aliases'], $settings['email']);
return null;
}
/**
* Execute (synchronously) specified step in a user setup process.
*
* @param \App\User $user User object
* @param string $step Step identifier (as in self::statusInfo())
*
* @return bool|null True if the execution succeeded, False if not, Null when
* the job has been sent to the worker (result unknown)
*/
public static function execProcessStep(User $user, string $step): ?bool
{
try {
if (strpos($step, 'domain-') === 0) {
return DomainsController::execProcessStep($user->domain(), $step);
}
switch ($step) {
case 'user-ldap-ready':
case 'user-imap-ready':
// Use worker to do the job, frontend might not have the IMAP admin credentials
\App\Jobs\User\CreateJob::dispatch($user->id);
return null;
}
} catch (\Exception $e) {
\Log::error($e);
}
return false;
}
/**
* Email address validation for use as a user mailbox (login).
*
* @param string $email Email address
* @param \App\User $user The account owner
* @param null|\App\User|\App\Group $deleted Filled with an instance of a deleted user or group
* with the specified email address, if exists
*
* @return ?string Error message on validation error
*/
public static function validateEmail(string $email, \App\User $user, &$deleted = null): ?string
{
$deleted = null;
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'email']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['email' => $login],
['email' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['email'][0];
}
// Check if it is one of domains available to the user
if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user/group/resource/shared folder with specified address already exists
if (
($existing = User::emailExists($email, true))
|| ($existing = \App\Group::emailExists($email, true))
|| ($existing = \App\Resource::emailExists($email, true))
|| ($existing = \App\SharedFolder::emailExists($email, true))
) {
// If this is a deleted user/group/resource/folder in the same custom domain
// we'll force delete it before creating the target user
if (!$domain->isPublic() && $existing->trashed()) {
$deleted = $existing;
} else {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
}
// Check if an alias with specified address already exists.
if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) {
return \trans('validation.entryexists', ['attribute' => 'email']);
}
return null;
}
/**
* Email address validation for use as an alias.
*
* @param string $email Email address
* @param \App\User $user The account owner
*
* @return ?string Error message on validation error
*/
public static function validateAlias(string $email, \App\User $user): ?string
{
if (strpos($email, '@') === false) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
list($login, $domain) = explode('@', Str::lower($email));
if (strlen($login) === 0 || strlen($domain) === 0) {
return \trans('validation.entryinvalid', ['attribute' => 'alias']);
}
// Check if domain exists
$domain = Domain::withObjectTenantContext($user)->where('namespace', $domain)->first();
if (empty($domain)) {
return \trans('validation.domaininvalid');
}
// Validate login part alone
$v = Validator::make(
['alias' => $login],
['alias' => ['required', new UserEmailLocal(!$domain->isPublic())]]
);
if ($v->fails()) {
return $v->errors()->toArray()['alias'][0];
}
// Check if it is one of domains available to the user
if (!$domain->isPublic() && $user->id != $domain->walletOwner()->id) {
return \trans('validation.entryexists', ['attribute' => 'domain']);
}
// Check if a user with specified address already exists
if ($existing_user = User::emailExists($email, true)) {
// Allow an alias in a custom domain to an address that was a user before
if ($domain->isPublic() || !$existing_user->trashed()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
// Check if a group/resource/shared folder with specified address already exists
if (
\App\Group::emailExists($email)
|| \App\Resource::emailExists($email)
|| \App\SharedFolder::emailExists($email)
) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
// Check if an alias with specified address already exists
if (User::aliasExists($email) || \App\SharedFolder::aliasExists($email)) {
// Allow assigning the same alias to a user in the same group account,
// but only for non-public domains
if ($domain->isPublic()) {
return \trans('validation.entryexists', ['attribute' => 'alias']);
}
}
return null;
}
/**
* Activate password reset code (if set), and assign it to a user.
*
* @param \App\User $user The user
*/
protected function activatePassCode(User $user): void
{
// Activate the password reset code
if ($this->passCode) {
$this->passCode->user_id = $user->id;
$this->passCode->active = true;
$this->passCode->save();
}
}
}
diff --git a/src/app/Http/Controllers/Controller.php b/src/app/Http/Controllers/Controller.php
index 73e7c7d5..defb6ddc 100644
--- a/src/app/Http/Controllers/Controller.php
+++ b/src/app/Http/Controllers/Controller.php
@@ -1,84 +1,100 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
class Controller extends BaseController
{
use AuthorizesRequests;
use DispatchesJobs;
use ValidatesRequests;
/**
* Common error response builder for API (JSON) responses
*
* @param int $code Error code
* @param string $message Error message
* @param array $data Additional response data
*
* @return \Illuminate\Http\JsonResponse
*/
public static function errorResponse(int $code, string $message = null, array $data = [])
{
$errors = [
400 => "Bad request",
401 => "Unauthorized",
403 => "Access denied",
404 => "Not found",
405 => "Method not allowed",
422 => "Input validation error",
429 => "Too many requests",
500 => "Internal server error",
];
$response = [
'status' => 'error',
'message' => $message ?: (isset($errors[$code]) ? $errors[$code] : "Server error"),
];
if (!empty($data)) {
$response = $response + $data;
}
return response()->json($response, $code);
}
/**
* Check if current user has access to the specified object
* by being an admin or existing in the same tenant context.
*
* @param ?object $object Model object
*
* @return bool
*/
protected function checkTenant(object $object = null): bool
{
if (empty($object)) {
return false;
}
$user = $this->guard()->user();
if ($user->role == 'admin') {
return true;
}
return $object->tenant_id == $user->tenant_id;
}
/**
* Get the guard to be used during authentication.
*
* @return \Illuminate\Contracts\Auth\Guard
*/
protected function guard()
{
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
index 4922cd38..7724dec2 100644
--- a/src/app/Jobs/User/CreateJob.php
+++ b/src/app/Jobs/User/CreateJob.php
@@ -1,108 +1,115 @@
<?php
namespace App\Jobs\User;
use App\Jobs\UserJob;
/**
* Create the \App\User in LDAP.
*
* Throws exceptions for the following reasons:
*
* * The user is marked as deleted (`$user->isDeleted()`), or
* * the user is actually deleted (`$user->deleted_at`), or
* * the user is already marked as ready in LDAP (`$user->isLdapReady()`).
*
*/
class CreateJob extends UserJob
{
/**
* Execute the job.
*
* @return void
*
* @throws \Exception
*/
public function handle()
{
$user = $this->getUser();
if (!$user) {
return;
}
if ($user->role) {
// Admins/resellers don't reside in LDAP (for now)
return;
}
if ($user->email == \config('imap.admin_login')) {
// Ignore Cyrus admin account
return;
}
// sanity checks
if ($user->isDeleted()) {
$this->fail(new \Exception("User {$this->userId} is marked as deleted."));
return;
}
if ($user->trashed()) {
$this->fail(new \Exception("User {$this->userId} is actually deleted."));
return;
}
$withLdap = \config('app.with_ldap');
// see if the domain is ready
$domain = $user->domain();
if (!$domain) {
$this->fail(new \Exception("The domain for {$this->userId} does not exist."));
return;
}
if ($domain->isDeleted()) {
$this->fail(new \Exception("The domain for {$this->userId} is marked as deleted."));
return;
}
if ($withLdap && !$domain->isLdapReady()) {
$this->release(60);
return;
}
if (\config('abuse.suspend_enabled') && !$user->isSuspended()) {
$code = \Artisan::call("user:abuse-check {$this->userId}");
if ($code == 2) {
\Log::info("Suspending user due to suspected abuse: {$this->userId} {$user->email}");
$user->status |= \App\User::STATUS_SUSPENDED;
}
}
if ($withLdap && !$user->isLdapReady()) {
\App\Backends\LDAP::createUser($user);
$user->status |= \App\User::STATUS_LDAP_READY;
$user->save();
}
if (!$user->isImapReady()) {
if (\config('app.with_imap')) {
if (!\App\Backends\IMAP::createUser($user)) {
throw new \Exception("Failed to create mailbox for user {$this->userId}.");
}
} else {
if (!\App\Backends\IMAP::verifyAccount($user->email)) {
$this->release(15);
return;
}
}
$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
index 56f3fc4f..6d7423cd 100644
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -1,205 +1,214 @@
<?php
namespace App;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
/**
* A payment operation on a wallet.
*
* @property int $amount Amount of money in cents of system currency (payment provider)
* @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
* @property string $currency Currency of this payment
* @property int $currency_amount Amount of money in cents of $currency
*/
class Payment extends Model
{
use NullableFields;
public const STATUS_OPEN = 'open';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PENDING = 'pending';
public const STATUS_AUTHORIZED = 'authorized';
public const STATUS_EXPIRED = 'expired';
public const STATUS_FAILED = 'failed';
public const STATUS_PAID = 'paid';
public const TYPE_ONEOFF = 'oneoff';
public const TYPE_RECURRING = 'recurring';
public const TYPE_MANDATE = 'mandate';
public const TYPE_REFUND = 'refund';
public const TYPE_CHARGEBACK = 'chargeback';
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
/** @var bool Indicates that the model should be timestamped or not */
public $incrementing = false;
/** @var string The "type" of the auto-incrementing ID */
protected $keyType = 'string';
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'amount' => 'integer',
'credit_amount' => 'integer',
'currency_amount' => 'integer',
];
/** @var array<int,string> The attributes that are mass assignable */
protected $fillable = [
'id',
'wallet_id',
'amount',
'credit_amount',
'description',
'provider',
'status',
'vat_rate_id',
'type',
'currency',
'currency_amount',
];
/** @var array<int, string> The attributes that can be not set */
protected $nullable = [
'vat_rate_id',
];
/**
* Create a payment record in DB from array.
*
* @param array $payment Payment information (required: id, type, wallet_id, currency, amount, currency_amount)
*
* @return \App\Payment Payment object
*/
public static function createFromArray(array $payment): Payment
{
$db_payment = new Payment();
$db_payment->id = $payment['id'];
$db_payment->description = $payment['description'] ?? '';
$db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
$db_payment->amount = $payment['amount'] ?? 0;
$db_payment->credit_amount = $payment['credit_amount'] ?? ($payment['amount'] ?? 0);
$db_payment->vat_rate_id = $payment['vat_rate_id'] ?? null;
$db_payment->type = $payment['type'];
$db_payment->wallet_id = $payment['wallet_id'];
$db_payment->provider = $payment['provider'] ?? '';
$db_payment->currency = $payment['currency'];
$db_payment->currency_amount = $payment['currency_amount'];
$db_payment->save();
return $db_payment;
}
/**
* Apply the successful payment's pecunia to the wallet
*
* @param string $method Payment method name
*/
public function credit($method): void
{
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}";
$this->wallet->credit($this, $description);
// Unlock the disabled auto-payment mandate
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);
+ 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();
+ }
}
}
/**
* Creates a payment and transaction records for the refund/chargeback operation.
* Deducts an amount of pecunia from the wallet.
*
* @param array $refund A refund or chargeback data (id, type, amount, currency, description)
*
* @return ?\App\Payment A payment object for the refund
*/
public function refund(array $refund): ?Payment
{
if (empty($refund) || empty($refund['amount'])) {
return null;
}
// Convert amount to wallet currency (use the same exchange rate as for the original payment)
// Note: We assume a refund is always using the same currency
$exchange_rate = $this->amount / $this->currency_amount;
$credit_amount = $amount = (int) round($refund['amount'] * $exchange_rate);
// Set appropriate credit_amount if original credit_amount != original amount
if ($this->amount != $this->credit_amount) {
$credit_amount = (int) round($amount * ($this->credit_amount / $this->amount));
}
// Apply the refund to the wallet balance
$method = $refund['type'] == self::TYPE_CHARGEBACK ? 'chargeback' : 'refund';
$this->wallet->{$method}($credit_amount, $refund['description'] ?? '');
$refund['amount'] = $amount * -1;
$refund['credit_amount'] = $credit_amount * -1;
$refund['currency_amount'] = round($amount * -1 / $exchange_rate);
$refund['currency'] = $this->currency;
$refund['wallet_id'] = $this->wallet_id;
$refund['provider'] = $this->provider;
$refund['vat_rate_id'] = $this->vat_rate_id;
$refund['status'] = self::STATUS_PAID;
// FIXME: Refunds/chargebacks are out of the reseller comissioning for now
return self::createFromArray($refund);
}
/**
* Ensure the currency is appropriately cased.
*/
public function setCurrencyAttribute($currency)
{
$this->attributes['currency'] = strtoupper($currency);
}
/**
* The wallet to which this payment belongs.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function wallet()
{
return $this->belongsTo(Wallet::class, 'wallet_id', 'id');
}
/**
* The VAT rate assigned to this payment.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function vatRate()
{
return $this->belongsTo(VatRate::class, 'vat_rate_id', 'id');
}
}
diff --git a/src/app/Plan.php b/src/app/Plan.php
index 6455235b..5b0bbf82 100644
--- a/src/app/Plan.php
+++ b/src/app/Plan.php
@@ -1,127 +1,132 @@
<?php
namespace App;
use App\Traits\BelongsToTenantTrait;
use App\Traits\UuidStrKeyTrait;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* The eloquent definition of a Plan.
*
* A Plan is a grouping of packages, such as a "Family Plan".
*
* A "Family Plan" as such may exist of "2 or more Kolab packages",
* and apply a discount for the third and further Kolab packages.
*
* @property string $description
* @property int $discount_qty
* @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
* @property datetime $promo_to
* @property ?int $tenant_id
* @property string $title
*/
class Plan extends Model
{
use BelongsToTenantTrait;
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 */
protected $fillable = [
'title',
'mode',
'name',
'description',
// a start and end datetime for this promotion
'promo_from',
'promo_to',
// discounts start at this quantity
'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',
];
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'promo_from' => 'datetime:Y-m-d H:i:s',
'promo_to' => 'datetime:Y-m-d H:i:s',
'discount_qty' => 'integer',
'discount_rate' => 'integer',
'months' => 'integer',
'free_months' => 'integer'
];
/** @var array<int, string> Translatable properties */
public $translatable = [
'name',
'description',
];
/**
* The list price for this package at the minimum configuration.
*
* @return int The costs in cents.
*/
public function cost()
{
$costs = 0;
foreach ($this->packages as $package) {
$costs += $package->pivot->cost();
}
return $costs;
}
/**
* The relationship to packages.
*
* The plan contains one or more packages. Each package may have its minimum number (for
* billing) or its maximum (to allow topping out "enterprise" customers on a "small business"
* plan).
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function packages()
{
return $this->belongsToMany(Package::class, 'plan_packages')
->using(PlanPackage::class)
->withPivot([
'qty',
'qty_min',
'qty_max',
'discount_qty',
'discount_rate'
]);
}
/**
* Checks if the plan has any type of domain SKU assigned.
*
* @return bool
*/
public function hasDomain(): bool
{
foreach ($this->packages as $package) {
if ($package->isDomain()) {
return true;
}
}
return false;
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
index fba98278..b31f2fd3 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,629 +1,630 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use Illuminate\Support\Facades\DB;
use Mollie\Api\Exceptions\ApiException;
use Mollie\Api\Types;
class Mollie extends \App\Providers\PaymentProvider
{
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'<a href="https://www.mollie.com/dashboard/customers/%s" target="_blank">%s</a>',
$customer_id,
$customer_id
);
}
/**
* Validates that mollie available.
*
* @throws \Mollie\Api\Exceptions\ApiException on failure
* @return bool true on success
*/
public static function healthcheck()
{
mollie()->methods()->allActive();
return true;
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (optional)
* - 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
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
if (!isset($payment['amount'])) {
$payment['amount'] = 0;
}
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'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']
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
if ($response->mandateId) {
$wallet->setSetting('mollie_mandate_id', $response->mandateId);
}
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$payment['type'] = Payment::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $response->id,
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
// Revoke the mandate on Mollie
if ($mandate) {
$mandate->revoke();
$wallet->setSetting('mollie_mandate_id', null);
}
return true;
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
if (empty($mandate)) {
return null;
}
$result = [
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
'method' => self::paymentMethod($mandate, 'Unknown method'),
'methodId' => $mandate->method
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'mollie';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == Payment::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
// Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required (note that JPK and ISK don't require decimals,
// but we're not using them currently)
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
'redirectUrl' => self::redirectUrl() // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
// billingEmail - for bank transfers, Przelewy24, but not creditcard
// billingAddress (it is a structured field not just text)
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Cancel a pending payment.
*
* @param \App\Wallet $wallet The wallet
* @param string $paymentId Payment Id
*
* @return bool True on success, False on failure
*/
public function cancel(Wallet $wallet, $paymentId): bool
{
$response = mollie()->payments()->delete($paymentId);
$db_payment = Payment::find($paymentId);
$db_payment->status = $response->status;
$db_payment->save();
return true;
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::mollieMandate($wallet);
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
\Log::debug("Recurring payment for {$wallet->id}: no valid Mollie mandate");
return null;
}
$customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
'mandateId' => $mandate->id
];
\Log::debug("Recurring payment for {$wallet->id}: " . json_encode($request));
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
DB::beginTransaction();
$payment = $this->storePayment($payment, $wallet->id);
// Mollie can return 'paid' status immediately, so we don't
// have to wait for the webhook. What's more, the webhook would ignore
// the payment because it will be marked as paid before the webhook.
// Let's handle paid status here too.
if ($response->isPaid()) {
self::creditPayment($payment, $response);
$notify = true;
} elseif ($response->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $response->id));
// Disable the mandate
$wallet->setSetting('mandate_disabled', 1);
$notify = true;
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
$payment_id = \request()->input('id');
if (empty($payment_id)) {
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
try {
// Get the payment details from Mollie
// TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed
$mollie_payment = mollie()->payments()->get($payment_id);
$refunds = [];
if ($mollie_payment->isPaid()) {
// The payment is paid. Update the balance, and notify the user
if ($payment->status != Payment::STATUS_PAID && $payment->amount >= 0) {
$credit = true;
$notify = $payment->type == Payment::TYPE_RECURRING;
}
// The payment has been (partially) refunded.
// Let's process refunds with status "refunded".
if ($mollie_payment->hasRefunds()) {
foreach ($mollie_payment->refunds() as $refund) {
if ($refund->isTransferred() && $refund->amount->value) {
$refunds[] = [
'id' => $refund->id,
'description' => $refund->description,
'amount' => round(floatval($refund->amount->value) * 100),
'type' => Payment::TYPE_REFUND,
'currency' => $refund->amount->currency
];
}
}
}
// The payment has been (partially) charged back.
// Let's process chargebacks (they have no states as refunds)
if ($mollie_payment->hasChargebacks()) {
foreach ($mollie_payment->chargebacks() as $chargeback) {
if ($chargeback->amount->value) {
$refunds[] = [
'id' => $chargeback->id,
'amount' => round(floatval($chargeback->amount->value) * 100),
'type' => Payment::TYPE_CHARGEBACK,
'currency' => $chargeback->amount->currency
];
}
}
}
// In case there were multiple auto-payment setup requests (e.g. caused by a double
// form submission) we end up with multiple payment records and mollie_mandate_id
// pointing to the one from the last payment not the successful one.
// We make sure to use mandate id from the successful "first" payment.
if (
$payment->type == Payment::TYPE_MANDATE
&& $mollie_payment->mandateId
&& $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST
) {
$payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId);
}
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
// Disable the mandate
if ($payment->type == Payment::TYPE_RECURRING) {
$notify = true;
$payment->wallet->setSetting('mandate_disabled', 1);
}
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
$pending_states = [Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment, $mollie_payment);
}
foreach ($refunds as $refund) {
$payment->refund($refund);
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
} catch (\Mollie\Api\Exceptions\ApiException $e) {
\Log::warning(sprintf('Mollie api call failed (%s)', $e->getMessage()));
}
return 200;
}
/**
* Get Mollie customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return ?string Mollie customer identifier
*/
protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('mollie_id');
// Register the user in Mollie
if (empty($customer_id) && $create) {
$customer = mollie()->customers()->create([
'name' => $wallet->owner->name(),
'email' => $wallet->id . '@private.' . \config('app.domain'),
]);
$customer_id = $customer->id;
$wallet->setSetting('mollie_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Mollie auto-payment mandate
*/
protected static function mollieMandate(Wallet $wallet)
{
$settings = $wallet->getSettings(['mollie_id', 'mollie_mandate_id']);
// Get the manadate reference we already have
if ($settings['mollie_id'] && $settings['mollie_mandate_id']) {
try {
return mollie()->mandates()->getForId($settings['mollie_id'], $settings['mollie_mandate_id']);
} catch (ApiException $e) {
// FIXME: What about 404?
if ($e->getCode() == 410) {
// The mandate is gone, remove the reference
$wallet->setSetting('mollie_mandate_id', null);
return null;
}
// TODO: Maybe we shouldn't always throw? It make sense in the job
// but for example when we're just fetching wallet info...
throw $e;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment($payment, $mollie_payment)
{
// Extract the payment method for transaction description
$method = self::paymentMethod($mollie_payment, 'Mollie');
$payment->credit($method);
}
/**
* Extract payment method description from Mollie payment/mandate details
*/
protected static function paymentMethod($object, $default = ''): string
{
$details = $object->details;
// Mollie supports 3 methods here
switch ($object->method) {
case self::METHOD_CREDITCARD:
// If the customer started, but never finished the 'first' payment
// card details will be empty, and mandate will be 'pending'.
if (empty($details->cardNumber)) {
return 'Credit Card';
}
return sprintf(
'%s (**** **** **** %s)',
$details->cardLabel ?: 'Card', // @phpstan-ignore-line
$details->cardNumber
);
case self::METHOD_DIRECTDEBIT:
return sprintf('Direct Debit (%s)', $details->customerAccount);
case self::METHOD_PAYPAL:
return sprintf('PayPal (%s)', $details->consumerAccount);
}
return $default;
}
/**
* List supported payment methods.
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public function providerPaymentMethods(string $type, string $currency): array
{
// Prefer methods in the system currency
$providerMethods = (array) mollie()->methods()->allActive(
[
'sequenceType' => $type,
'amount' => [
'value' => '1.00',
'currency' => $currency
]
]
);
// Get EUR methods (e.g. bank transfers are in EUR only)
if ($currency != 'EUR') {
$eurMethods = (array) mollie()->methods()->allActive(
[
'sequenceType' => $type,
'amount' => [
'value' => '1.00',
'currency' => 'EUR'
]
]
);
// Later provider methods will override earlier ones
$providerMethods = array_merge($eurMethods, $providerMethods);
}
$availableMethods = [];
foreach ($providerMethods as $method) {
$availableMethods[$method->id] = [
'id' => $method->id,
'name' => $method->description,
'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents
'currency' => $method->minimumAmount->currency,
'exchangeRate' => \App\Utils::exchangeRate($currency, $method->minimumAmount->currency)
];
}
return $availableMethods;
}
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
public function getPayment($paymentId): array
{
$payment = mollie()->payments()->get($paymentId);
return [
'id' => $payment->id,
'status' => $payment->status,
'isCancelable' => $payment->isCancelable,
'checkoutUrl' => $payment->getCheckoutUrl()
];
}
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
index 19ba14c1..64617d4a 100644
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -1,554 +1,555 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use App\WalletSetting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Request;
use Stripe as StripeAPI;
class Stripe extends \App\Providers\PaymentProvider
{
/**
* Class constructor.
*/
public function __construct()
{
StripeAPI\Stripe::setApiKey(\config('services.stripe.key'));
}
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::stripeCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
$location = 'https://dashboard.stripe.com';
$key = \config('services.stripe.key');
if (strpos($key, 'sk_test_') === 0) {
$location .= '/test';
}
return sprintf(
'<a href="%s/customers/%s" target="_blank">%s</a>',
$location,
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (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
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
$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',
];
// Note: Stripe does not allow to set amount for 'setup' operation
// We'll dispatch WalletCharge job when we receive a webhook request
$session = StripeAPI\Checkout\Session::create($request);
$payment['amount'] = 0;
$payment['credit_amount'] = 0;
$payment['currency_amount'] = 0;
$payment['vat_rate_id'] = null;
$payment['id'] = $session->setup_intent;
$payment['type'] = Payment::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Revoke the auto-payment mandate.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if ($mandate) {
// Remove the reference
$wallet->setSetting('stripe_mandate_id', null);
// Detach the payment method on Stripe
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$pm->detach();
}
return true;
}
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$result = [
'id' => $mandate->id,
'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
'isValid' => $mandate->status == 'succeeded',
'method' => self::paymentMethod($pm, 'Unknown method')
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'stripe';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation desc.
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
public function payment(Wallet $wallet, array $payment): ?array
{
if ($payment['type'] == Payment::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'customer' => $customer_id,
'cancel_url' => self::redirectUrl(), // required
'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'line_items' => [
[
'name' => $payment['description'],
'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'quantity' => 1,
]
]
];
$session = StripeAPI\Checkout\Session::create($request);
// Store the payment reference in database
$payment['id'] = $session->payment_intent;
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'description' => $payment['description'],
'receipt_email' => $wallet->owner->email,
'customer' => $mandate->customer,
'payment_method' => $mandate->payment_method,
'off_session' => true,
'confirm' => true,
];
$intent = StripeAPI\PaymentIntent::create($request);
// Store the payment reference in database
$payment['id'] = $intent->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
// We cannot just use php://input as it's already "emptied" by the framework
// $payload = file_get_contents('php://input');
$request = Request::instance();
$payload = $request->getContent();
$sig_header = $request->header('Stripe-Signature');
// Parse and validate the input
try {
$event = StripeAPI\Webhook::constructEvent(
$payload,
$sig_header,
\config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
\Log::error("Invalid payload: " . $e->getMessage());
// Invalid payload
return 400;
}
switch ($event->type) {
case StripeAPI\Event::PAYMENT_INTENT_CANCELED:
case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED:
case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
if (empty($payment) || $payment->type == Payment::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
$status = Payment::STATUS_CANCELED;
break;
case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
$status = Payment::STATUS_PAID;
break;
default:
$status = Payment::STATUS_FAILED;
}
DB::beginTransaction();
if ($status == Payment::STATUS_PAID) {
// Update the balance, if it wasn't already
if ($payment->status != Payment::STATUS_PAID) {
$this->creditPayment($payment, $intent);
}
} else {
if (!empty($intent->last_payment_error)) {
// See https://stripe.com/docs/error-codes for more info
\Log::info(sprintf(
'Stripe payment failed (%s): %s',
$payment->id,
json_encode($intent->last_payment_error)
));
}
}
if ($payment->status != Payment::STATUS_PAID) {
$payment->status = $status;
$payment->save();
if ($status != Payment::STATUS_CANCELED && $payment->type == Payment::TYPE_RECURRING) {
// Disable the mandate
if ($status == Payment::STATUS_FAILED) {
$payment->wallet->setSetting('mandate_disabled', 1);
}
// Notify the user
\App\Jobs\PaymentEmail::dispatch($payment);
}
}
DB::commit();
break;
case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED:
case StripeAPI\Event::SETUP_INTENT_CANCELED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
if (empty($payment) || $payment->type != Payment::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\SetupIntent::STATUS_CANCELED:
$status = Payment::STATUS_CANCELED;
break;
case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
$status = Payment::STATUS_PAID;
break;
default:
$status = Payment::STATUS_FAILED;
}
if ($status == Payment::STATUS_PAID) {
$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);
}
}
$payment->status = $status;
$payment->save();
break;
default:
\Log::debug("Unhandled Stripe event: " . var_export($payload, true));
break;
}
return 200;
}
/**
* Get Stripe customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return string|null Stripe customer identifier
*/
protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('stripe_id');
// Register the user in Stripe
if (empty($customer_id) && $create) {
$customer = StripeAPI\Customer::create([
'name' => $wallet->owner->name(),
// Stripe will display the email on Checkout page, editable,
// and use it to send the receipt (?), use the user email here
// 'email' => $wallet->id . '@private.' . \config('app.domain'),
'email' => $wallet->owner->email,
]);
$customer_id = $customer->id;
$wallet->setSetting('stripe_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Stripe auto-payment mandate (Setup Intent)
*/
protected static function stripeMandate(Wallet $wallet)
{
// Note: Stripe also has 'Mandate' objects, but we do not use these
if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) {
$mandate = StripeAPI\SetupIntent::retrieve($mandate_id);
// @phpstan-ignore-next-line
if ($mandate && $mandate->status != 'canceled') {
return $mandate;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment(Payment $payment, $intent)
{
$method = 'Stripe';
// Extract the payment method for transaction description
if (
!empty($intent->charges)
&& ($charge = $intent->charges->data[0])
&& ($pm = $charge->payment_method_details)
) {
$method = self::paymentMethod($pm);
}
$payment->credit($method);
}
/**
* Extract payment method description from Stripe payment details
*/
protected static function paymentMethod($details, $default = ''): string
{
switch ($details->type) {
case 'card':
// TODO: card number
return \sprintf(
'%s (**** **** **** %s)',
\ucfirst($details->card->brand) ?: 'Card',
$details->card->last4
);
}
return $default;
}
/**
* List supported payment methods.
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public function providerPaymentMethods(string $type, string $currency): array
{
//TODO get this from the stripe API?
$availableMethods = [];
switch ($type) {
case Payment::TYPE_ONEOFF:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
'minimumAmount' => Payment::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
],
self::METHOD_PAYPAL => [
'id' => self::METHOD_PAYPAL,
'name' => "PayPal",
'minimumAmount' => Payment::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
]
];
break;
case Payment::TYPE_RECURRING:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
'minimumAmount' => Payment::MIN_AMOUNT, // Converted to cents,
'currency' => $currency,
'exchangeRate' => 1.0
]
];
break;
}
return $availableMethods;
}
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
public function getPayment($paymentId): array
{
\Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl.");
$payment = StripeAPI\PaymentIntent::retrieve($paymentId);
return [
'id' => $payment->id,
'status' => $payment->status,
'isCancelable' => false,
'checkoutUrl' => null
];
}
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
index ad78a1ac..02ea6c0b 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,329 +1,330 @@
<?php
namespace App\Providers;
use App\Payment;
use App\Transaction;
use App\Wallet;
use Illuminate\Support\Facades\Cache;
abstract class PaymentProvider
{
public const METHOD_CREDITCARD = 'creditcard';
public const METHOD_PAYPAL = 'paypal';
public const METHOD_BANKTRANSFER = 'banktransfer';
public const METHOD_DIRECTDEBIT = 'directdebit';
public const METHOD_BITCOIN = 'bitcoin';
public const PROVIDER_MOLLIE = 'mollie';
public const PROVIDER_STRIPE = 'stripe';
public const PROVIDER_COINBASE = 'coinbase';
private static $paymentMethodIcons = [
self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'],
self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'],
self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'building-columns'],
self::METHOD_BITCOIN => ['prefix' => 'fab', 'name' => 'bitcoin'],
];
/**
* Detect the name of the provider
*
* @param \App\Wallet|string|null $provider_or_wallet
* @return string The name of the provider
*/
private static function providerName($provider_or_wallet = null): string
{
if ($provider_or_wallet instanceof Wallet) {
$settings = $provider_or_wallet->getSettings(['stripe_id', 'mollie_id']);
if ($settings['stripe_id']) {
$provider = self::PROVIDER_STRIPE;
} elseif ($settings['mollie_id']) {
$provider = self::PROVIDER_MOLLIE;
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
$provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE;
}
return \strtolower($provider);
}
/**
* Factory method
*
* @param \App\Wallet|string|null $provider_or_wallet
*/
public static function factory($provider_or_wallet = null, $currency = null)
{
if (\strtolower($currency) == 'btc') {
return new \App\Providers\Payment\Coinbase();
}
switch (self::providerName($provider_or_wallet)) {
case self::PROVIDER_STRIPE:
return new \App\Providers\Payment\Stripe();
case self::PROVIDER_MOLLIE:
return new \App\Providers\Payment\Mollie();
case self::PROVIDER_COINBASE:
return new \App\Providers\Payment\Coinbase();
default:
throw new \Exception("Invalid payment provider: {$provider_or_wallet}");
}
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (wallet currency)
* - credit_amount: Balance'able base amount in cents (wallet currency)
* - vat_rate_id: VAT rate id
* - 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
* - redirectUrl: the location to redirect to
*/
abstract public function createMandate(Wallet $wallet, array $payment): ?array;
/**
* Revoke the auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
abstract public function deleteMandate(Wallet $wallet): bool;
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
abstract public function getMandate(Wallet $wallet): ?array;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
abstract public function customerLink(Wallet $wallet): ?string;
/**
* Get a provider name
*
* @return string Provider name
*/
abstract public function name(): string;
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (wallet currency)
* - credit_amount: Balance'able base amount in cents (wallet currency)
* - vat_rate_id: Vat rate id
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
* - methodId: Payment method
*
* @return array Provider payment/session data:
* - id: Operation identifier
* - redirectUrl
*/
abstract public function payment(Wallet $wallet, array $payment): ?array;
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
abstract public function webhook(): int;
/**
* Create a payment record in DB
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
*
* @return \App\Payment Payment object
*/
protected function storePayment(array $payment, $wallet_id): Payment
{
$payment['wallet_id'] = $wallet_id;
$payment['provider'] = $this->name();
return Payment::createFromArray($payment);
}
/**
* Convert a value from $sourceCurrency to $targetCurrency
*
* @param int $amount Amount in cents of $sourceCurrency
* @param string $sourceCurrency Currency from which to convert
* @param string $targetCurrency Currency to convert to
*
* @return int Exchanged amount in cents of $targetCurrency
*/
protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int
{
return intval(round($amount * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency)));
}
/**
* List supported payment methods from this provider
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
abstract public function providerPaymentMethods(string $type, string $currency): array;
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
abstract public function getPayment($paymentId): array;
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
protected static function paymentMethodsWhitelist($type): array
{
$methods = [];
switch ($type) {
case Payment::TYPE_ONEOFF:
$methods = explode(',', \config('app.payment.methods_oneoff'));
break;
case Payment::TYPE_RECURRING:
$methods = explode(',', \config('app.payment.methods_recurring'));
break;
default:
\Log::error("Unknown payment type: " . $type);
}
$methods = array_map('strtolower', array_map('trim', $methods));
return $methods;
}
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
private static function applyMethodWhitelist($type, $availableMethods): array
{
$methods = [];
// Use only whitelisted methods, and apply values from whitelist (overriding the backend)
$whitelistMethods = self::paymentMethodsWhitelist($type);
foreach ($whitelistMethods as $id) {
if (array_key_exists($id, $availableMethods)) {
$method = $availableMethods[$id];
$method['icon'] = self::$paymentMethodIcons[$id];
$methods[] = $method;
}
}
return $methods;
}
/**
* List supported payment methods for $wallet
*
* @param \App\Wallet $wallet The wallet
* @param string $type The payment type for which we require a method (oneoff/recurring).
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public static function paymentMethods(Wallet $wallet, $type): array
{
$providerName = self::providerName($wallet);
$cacheKey = "methods-{$providerName}-{$type}-{$wallet->currency}";
if ($methods = Cache::get($cacheKey)) {
\Log::debug("Using payment method cache" . var_export($methods, true));
return $methods;
}
$provider = PaymentProvider::factory($providerName);
$methods = $provider->providerPaymentMethods($type, $wallet->currency);
if (!empty(\config('services.coinbase.key'))) {
$coinbaseProvider = PaymentProvider::factory(self::PROVIDER_COINBASE);
$methods = array_merge($methods, $coinbaseProvider->providerPaymentMethods($type, $wallet->currency));
}
$methods = self::applyMethodWhitelist($type, $methods);
\Log::debug("Loaded payment methods" . var_export($methods, true));
Cache::put($cacheKey, $methods, now()->addHours(1));
return $methods;
}
/**
* Returns the full URL for the wallet page, used when returning from an external payment page.
* Depending on the request origin it will return a URL for the User or Reseller UI.
*
* @return string The redirect URL
*/
public static function redirectUrl(): string
{
$url = \App\Utils::serviceUrl('/wallet');
$domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
if (strpos($domain, 'reseller') === 0) {
$url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url);
}
return $url;
}
}
diff --git a/src/app/Utils.php b/src/app/Utils.php
index 50f6aee1..5e2058e8 100644
--- a/src/app/Utils.php
+++ b/src/app/Utils.php
@@ -1,579 +1,600 @@
<?php
namespace App;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* Small utility functions for App.
*/
class Utils
{
// Note: Removed '0', 'O', '1', 'I' as problematic with some fonts
public const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ';
/**
* Exchange rates for unit tests
*/
private static $testRates;
/**
* Count the number of lines in a file.
*
* Useful for progress bars.
*
* @param string $file The filepath to count the lines of.
*
* @return int
*/
public static function countLines($file)
{
$fh = fopen($file, 'rb');
$numLines = 0;
while (!feof($fh)) {
$numLines += substr_count(fread($fh, 8192), "\n");
}
fclose($fh);
return $numLines;
}
/**
* Return the country ISO code for an IP address.
*
* @param string $ip IP address
* @param string $fallback Fallback country code
*
* @return string
*/
public static function countryForIP($ip, $fallback = 'CH')
{
if (strpos($ip, ':') === false) {
$net = \App\IP4Net::getNet($ip);
} else {
$net = \App\IP6Net::getNet($ip);
}
return $net && $net->country ? $net->country : $fallback;
}
/**
* Return the country ISO code for the current request.
*/
public static function countryForRequest()
{
$request = \request();
$ip = $request->ip();
return self::countryForIP($ip);
}
/**
* Return the number of days in the month prior to this one.
*
* @return int
*/
public static function daysInLastMonth()
{
$start = new Carbon('first day of last month');
$end = new Carbon('last day of last month');
return $start->diffInDays($end) + 1;
}
/**
* Download a file from the interwebz and store it locally.
*
* @param string $source The source location
* @param string $target The target location
* @param bool $force Force the download (and overwrite target)
*
* @return void
*/
public static function downloadFile($source, $target, $force = false)
{
if (is_file($target) && !$force) {
return;
}
\Log::info("Retrieving {$source}");
$fp = fopen($target, 'w');
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $source);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FILE, $fp);
curl_exec($curl);
if (curl_errno($curl)) {
\Log::error("Request error on {$source}: " . curl_error($curl));
curl_close($curl);
fclose($fp);
unlink($target);
return;
}
curl_close($curl);
fclose($fp);
}
/**
* Converts an email address to lower case. Keeps the LMTP shared folder
* addresses character case intact.
*
* @param string $email Email address
*
* @return string Email address
*/
public static function emailToLower(string $email): string
{
// For LMTP shared folder address lower case the domain part only
if (str_starts_with($email, 'shared+shared/')) {
$pos = strrpos($email, '@');
$domain = substr($email, $pos + 1);
$local = substr($email, 0, strlen($email) - strlen($domain) - 1);
return $local . '@' . strtolower($domain);
}
return strtolower($email);
}
/**
* Generate a passphrase. Not intended for use in production, so limited to environments that are not production.
*
* @return string
*/
public static function generatePassphrase()
{
if (\config('app.env') == 'production') {
throw new \Exception("Thou shall not pass!");
}
if (\config('app.passphrase')) {
return \config('app.passphrase');
}
$alphaLow = 'abcdefghijklmnopqrstuvwxyz';
$alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$num = '0123456789';
$stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<';
$source = $alphaLow . $alphaUp . $num . $stdSpecial;
$result = '';
for ($x = 0; $x < 16; $x++) {
$result .= substr($source, rand(0, (strlen($source) - 1)), 1);
}
return $result;
}
/**
* Find an object that is the recipient for the specified address.
*
* @param string $address
*
* @return array
*/
public static function findObjectsByRecipientAddress($address)
{
$address = \App\Utils::normalizeAddress($address);
list($local, $domainName) = explode('@', $address);
$domain = \App\Domain::where('namespace', $domainName)->first();
if (!$domain) {
return [];
}
$user = \App\User::where('email', $address)->first();
if ($user) {
return [$user];
}
$userAliases = \App\UserAlias::where('alias', $address)->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
$userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get();
if (count($userAliases) > 0) {
$users = [];
foreach ($userAliases as $userAlias) {
$users[] = $userAlias->user;
}
return $users;
}
return [];
}
/**
* Retrieve the network ID and Type from a client address
*
* @param string $clientAddress The IPv4 or IPv6 address.
*
* @return array An array of ID and class or null and null.
*/
public static function getNetFromAddress($clientAddress)
{
if (strpos($clientAddress, ':') === false) {
$net = \App\IP4Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP4Net::class];
}
} else {
$net = \App\IP6Net::getNet($clientAddress);
if ($net) {
return [$net->id, \App\IP6Net::class];
}
}
return [null, null];
}
/**
* Calculate the broadcast address provided a net number and a prefix.
*
* @param string $net A valid IPv6 network number.
* @param int $prefix The network prefix.
*
* @return string
*/
public static function ip6Broadcast($net, $prefix)
{
$netHex = bin2hex(inet_pton($net));
// Overwriting first address string to make sure notation is optimal
$net = inet_ntop(hex2bin($netHex));
// Calculate the number of 'flexible' bits
$flexbits = 128 - $prefix;
// Build the hexadecimal string of the last address
$lastAddrHex = $netHex;
// We start at the end of the string (which is always 32 characters long)
$pos = 31;
while ($flexbits > 0) {
// Get the character at this position
$orig = substr($lastAddrHex, $pos, 1);
// Convert it to an integer
$origval = hexdec($orig);
// OR it with (2^flexbits)-1, with flexbits limited to 4 at a time
$newval = $origval | (pow(2, min(4, $flexbits)) - 1);
// Convert it back to a hexadecimal character
$new = dechex($newval);
// And put that character back in the string
$lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1);
// We processed one nibble, move to previous position
$flexbits -= 4;
$pos -= 1;
}
// Convert the hexadecimal string to a binary string
$lastaddrbin = hex2bin($lastAddrHex);
// And create an IPv6 address from the binary string
$lastaddrstr = inet_ntop($lastaddrbin);
return $lastaddrstr;
}
/**
* Normalize an email address.
*
* This means to lowercase and strip components separated with recipient delimiters.
*
* @param ?string $address The address to normalize
* @param bool $asArray Return an array with local and domain part
*
* @return string|array Normalized email address as string or array
*/
public static function normalizeAddress(?string $address, bool $asArray = false)
{
if ($address === null || $address === '') {
return $asArray ? ['', ''] : '';
}
$address = self::emailToLower($address);
if (strpos($address, '@') === false) {
return $asArray ? [$address, ''] : $address;
}
list($local, $domain) = explode('@', $address);
if (strpos($local, '+') !== false) {
$local = explode('+', $local)[0];
}
return $asArray ? [$local, $domain] : "{$local}@{$domain}";
}
/**
* Provide all unique combinations of elements in $input, with order and duplicates irrelevant.
*
* @param array $input The input array of elements.
*
* @return array[]
*/
public static function powerSet(array $input): array
{
$output = [];
for ($x = 0; $x < count($input); $x++) {
self::combine($input, $x + 1, 0, [], 0, $output);
}
return $output;
}
/**
* Returns the current user's email address or null.
*
* @return string
*/
public static function userEmailOrNull(): ?string
{
$user = Auth::user();
if (!$user) {
return null;
}
return $user->email;
}
/**
* Returns a random string consisting of a quantity of segments of a certain length joined.
*
* Example:
*
* ```php
* $roomName = strtolower(\App\Utils::randStr(3, 3, '-');
* // $roomName == '3qb-7cs-cjj'
* ```
*
* @param int $length The length of each segment
* @param int $qty The quantity of segments
* @param string $join The string to use to join the segments
*
* @return string
*/
public static function randStr($length, $qty = 1, $join = '')
{
$chars = env('SHORTCODE_CHARS', self::CHARS);
$randStrs = [];
for ($x = 0; $x < $qty; $x++) {
$randStrs[$x] = [];
for ($y = 0; $y < $length; $y++) {
$randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)];
}
shuffle($randStrs[$x]);
$randStrs[$x] = implode('', $randStrs[$x]);
}
return implode($join, $randStrs);
}
/**
* Returns a UUID in the form of an integer.
*
* @return int
*/
public static function uuidInt(): int
{
$hex = self::uuidStr();
$bin = pack('h*', str_replace('-', '', $hex));
$ids = unpack('L', $bin);
$id = array_shift($ids);
return $id;
}
/**
* Returns a UUID in the form of a string.
*
* @return string
*/
public static function uuidStr(): string
{
return (string) Str::uuid();
}
private static function combine($input, $r, $index, $data, $i, &$output): void
{
$n = count($input);
// Current cobination is ready
if ($index == $r) {
$output[] = array_slice($data, 0, $r);
return;
}
// When no more elements are there to put in data[]
if ($i >= $n) {
return;
}
// current is included, put next at next location
$data[$index] = $input[$i];
self::combine($input, $r, $index + 1, $data, $i + 1, $output);
// current is excluded, replace it with next (Note that i+1
// is passed, but index is not changed)
self::combine($input, $r, $index, $data, $i + 1, $output);
}
/**
* Create self URL
*
* @param string $route Route/Path/URL
* @param int|null $tenantId Current tenant
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return string Full URL
*/
public static function serviceUrl(string $route, $tenantId = null): string
{
if (preg_match('|^https?://|i', $route)) {
return $route;
}
$url = \App\Tenant::getConfig($tenantId, 'app.public_url');
if (!$url) {
$url = \App\Tenant::getConfig($tenantId, 'app.url');
}
return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/');
}
/**
* Create a configuration/environment data to be passed to
* the UI
*
* @todo Move this to App\Http\Controllers\Controller
*
* @return array Configuration data
*/
public static function uiEnv(): array
{
$countries = include resource_path('countries.php');
$req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
$sys_domain = \config('app.domain');
$opts = [
'app.name',
'app.url',
'app.domain',
'app.theme',
'app.webmail_url',
'app.support_email',
'app.company.copyright',
'app.companion_download_link',
'mail.from.address'
];
$env = \app('config')->getMany($opts);
$env['countries'] = $countries ?: [];
$env['view'] = 'root';
$env['jsapp'] = 'user.js';
if ($req_domain == "admin.$sys_domain") {
$env['jsapp'] = 'admin.js';
} elseif ($req_domain == "reseller.$sys_domain") {
$env['jsapp'] = 'reseller.js';
}
$env['paymentProvider'] = \config('services.payment_provider');
$env['stripePK'] = \config('services.stripe.public_key');
$env['languages'] = \App\Http\Controllers\ContentController::locales();
$env['menu'] = \App\Http\Controllers\ContentController::menu();
return $env;
}
/**
* Set test exchange rates.
*
* @param array $rates: Exchange rates
*/
public static function setTestExchangeRates(array $rates): void
{
self::$testRates = $rates;
}
/**
* Retrieve an exchange rate.
*
* @param string $sourceCurrency: Currency from which to convert
* @param string $targetCurrency: Currency to convert to
*
* @return float Exchange rate
*/
public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float
{
if (strcasecmp($sourceCurrency, $targetCurrency) == 0) {
return 1.0;
}
if (isset(self::$testRates[$targetCurrency])) {
return floatval(self::$testRates[$targetCurrency]);
}
$currencyFile = resource_path("exchangerates-$sourceCurrency.php");
//Attempt to find the reverse exchange rate, if we don't have the file for the source currency
if (!file_exists($currencyFile)) {
$rates = include resource_path("exchangerates-$targetCurrency.php");
if (!isset($rates[$sourceCurrency])) {
throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency);
}
return 1.0 / floatval($rates[$sourceCurrency]);
}
$rates = include $currencyFile;
if (!isset($rates[$targetCurrency])) {
throw new \Exception("Failed to find exchange rate for " . $targetCurrency);
}
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
index 9554a5a0..9b980089 100644
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -1,733 +1,728 @@
<?php
namespace App;
use App\Traits\SettingsTrait;
use App\Traits\UuidStrKeyTrait;
use Carbon\Carbon;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* The eloquent definition of a wallet -- a container with a chunk of change.
*
* A wallet is owned by an {@link \App\User}.
*
* @property int $balance Current balance in cents
* @property string $currency Currency code
* @property ?string $description Description
* @property string $id Unique identifier
* @property ?\App\User $owner Owner (can be null when owner is deleted)
* @property int $user_id Owner's identifier
*/
class Wallet extends Model
{
use NullableFields;
use SettingsTrait;
use UuidStrKeyTrait;
/** @var bool Indicates that the model should be timestamped or not */
public $timestamps = false;
/** @var array The attributes' default values */
protected $attributes = [
'balance' => 0,
];
/** @var array<int, string> The attributes that are mass assignable */
protected $fillable = [
'currency',
'description'
];
/** @var array<int, string> The attributes that can be not set */
protected $nullable = [
'description',
];
/** @var array<string, string> The types of attributes to which its values will be cast */
protected $casts = [
'balance' => 'integer',
];
/**
* Add a controller to this wallet.
*
* @param \App\User $user The user to add as a controller to this wallet.
*
* @return void
*/
public function addController(User $user)
{
if (!$this->controllers->contains($user)) {
$this->controllers()->save($user);
}
}
/**
* Add an award to this wallet's balance.
*
* @param int|\App\Payment $amount The amount of award (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public function award(int|Payment $amount, string $description = ''): Wallet
{
return $this->balanceUpdate(Transaction::WALLET_AWARD, $amount, $description);
}
/**
* Charge a specific entitlement (for use on entitlement delete).
*
* @param \App\Entitlement $entitlement The entitlement.
*/
public function chargeEntitlement(Entitlement $entitlement): void
{
// Sanity checks
if ($entitlement->trashed() || $entitlement->wallet->id != $this->id || !$this->owner) {
return;
}
// Start calculating the costs for the consumption of this entitlement if the
// existing consumption spans >= 14 days.
//
// Effect is that anything's free for the first 14 days
if ($entitlement->created_at >= Carbon::now()->subDays(14)) {
return;
}
if ($this->owner->isDegraded()) {
return;
}
$now = Carbon::now();
// Determine if we're still within the trial period
$trial = $this->trialInfo();
if (
!empty($trial)
&& $entitlement->updated_at < $trial['end']
&& in_array($entitlement->sku_id, $trial['skus'])
) {
if ($trial['end'] >= $now) {
return;
}
$entitlement->updated_at = $trial['end'];
}
// get the discount rate applied to the wallet.
$discount = $this->getDiscountRate();
// just in case this had not been billed yet, ever
$diffInMonths = $entitlement->updated_at->diffInMonths($now);
$cost = (int) ($entitlement->cost * $discount * $diffInMonths);
$fee = (int) ($entitlement->fee * $diffInMonths);
// this moves the hypothetical updated at forward to however many months past the original
$updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths);
// now we have the diff in days since the last "billed" period end.
// This may be an entitlement paid up until February 28th, 2020, with today being March
// 12th 2020. Calculating the costs for the entitlement is based on the daily price
// the price per day is based on the number of days in the last month
// or the current month if the period does not overlap with the previous month
// FIXME: This really should be simplified to $daysInMonth=30
$diffInDays = $updatedAt->diffInDays($now);
if ($now->day >= $diffInDays) {
$daysInMonth = $now->daysInMonth;
} else {
$daysInMonth = \App\Utils::daysInLastMonth();
}
$pricePerDay = $entitlement->cost / $daysInMonth;
$feePerDay = $entitlement->fee / $daysInMonth;
$cost += (int) (round($pricePerDay * $discount * $diffInDays, 0));
$fee += (int) (round($feePerDay * $diffInDays, 0));
$profit = $cost - $fee;
if ($profit != 0 && $this->owner->tenant && ($wallet = $this->owner->tenant->wallet())) {
$desc = "Charged user {$this->owner->email}";
$method = $profit > 0 ? 'credit' : 'debit';
$wallet->{$method}(abs($profit), $desc);
}
if ($cost == 0) {
return;
}
// TODO: Create per-entitlement transaction record?
$this->debit($cost);
}
/**
* Charge entitlements in the wallet
*
* @param bool $apply Set to false for a dry-run mode
*
* @return int Charged amount in cents
*/
public function chargeEntitlements($apply = true): int
{
$transactions = [];
$profit = 0;
$charges = 0;
$discount = $this->getDiscountRate();
$isDegraded = $this->owner->isDegraded();
$trial = $this->trialInfo();
if ($apply) {
DB::beginTransaction();
}
// Get all entitlements...
$entitlements = $this->entitlements()
// Skip entitlements created less than or equal to 14 days ago (this is at
// maximum the fourteenth 24-hour period).
// ->where('created_at', '<=', Carbon::now()->subDays(14))
// Skip entitlements created, or billed last, less than a month ago.
->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1))
->get();
foreach ($entitlements as $entitlement) {
// If in trial, move entitlement's updated_at timestamps forward to the trial end.
if (
!empty($trial)
&& $entitlement->updated_at < $trial['end']
&& in_array($entitlement->sku_id, $trial['skus'])
) {
// TODO: Consider not updating the updated_at to a future date, i.e. bump it
// as many months as possible, but not into the future
// if we're in dry-run, you know...
if ($apply) {
$entitlement->updated_at = $trial['end'];
$entitlement->save();
}
continue;
}
$diff = $entitlement->updated_at->diffInMonths(Carbon::now());
if ($diff <= 0) {
continue;
}
$cost = (int) ($entitlement->cost * $discount * $diff);
$fee = (int) ($entitlement->fee * $diff);
if ($isDegraded) {
$cost = 0;
}
$charges += $cost;
$profit += $cost - $fee;
// if we're in dry-run, you know...
if (!$apply) {
continue;
}
$entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff);
$entitlement->save();
if ($cost == 0) {
continue;
}
$transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost);
}
if ($apply) {
$this->debit($charges, '', $transactions);
// Credit/debit the reseller
if ($profit != 0 && $this->owner->tenant) {
// FIXME: Should we have a simpler way to skip this for non-reseller tenant(s)
if ($wallet = $this->owner->tenant->wallet()) {
$desc = "Charged user {$this->owner->email}";
$method = $profit > 0 ? 'credit' : 'debit';
$wallet->{$method}(abs($profit), $desc);
}
}
DB::commit();
}
return $charges;
}
/**
* Calculate for how long the current balance will last.
*
* Returns NULL for balance < 0 or discount = 100% or on a fresh account
*
* @return \Carbon\Carbon|null Date
*/
public function balanceLastsUntil()
{
if ($this->balance < 0 || $this->getDiscount() == 100) {
return null;
}
$balance = $this->balance;
$discount = $this->getDiscountRate();
$trial = $this->trialInfo();
// Get all entitlements...
$entitlements = $this->entitlements()->orderBy('updated_at')->get()
->filter(function ($entitlement) {
return $entitlement->cost > 0;
})
->map(function ($entitlement) {
return [
'date' => $entitlement->updated_at ?: $entitlement->created_at,
'cost' => $entitlement->cost,
'sku_id' => $entitlement->sku_id,
];
})
->all();
$max = 12 * 25;
while ($max > 0) {
foreach ($entitlements as &$entitlement) {
$until = $entitlement['date'] = $entitlement['date']->addMonthsWithoutOverflow(1);
if (
!empty($trial)
&& $entitlement['date'] < $trial['end']
&& in_array($entitlement['sku_id'], $trial['skus'])
) {
continue;
}
$balance -= (int) ($entitlement['cost'] * $discount);
if ($balance < 0) {
break 2;
}
}
$max--;
}
if (empty($until)) {
return null;
}
// Don't return dates from the past
if ($until <= Carbon::now() && !$until->isToday()) {
return null;
}
return $until;
}
/**
* Chargeback an amount of pecunia from this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to charge back (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public function chargeback(int|Payment $amount, string $description = ''): Wallet
{
return $this->balanceUpdate(Transaction::WALLET_CHARGEBACK, $amount, $description);
}
/**
* Controllers of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function controllers()
{
return $this->belongsToMany(
User::class, // The foreign object definition
'user_accounts', // The table name
'wallet_id', // The local foreign key
'user_id' // The remote foreign key
);
}
/**
* Add an amount of pecunia to this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to add (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public function credit(int|Payment $amount, string $description = ''): Wallet
{
return $this->balanceUpdate(Transaction::WALLET_CREDIT, $amount, $description);
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to deduct (in cents) or Payment object
* @param string $description The transaction description
* @param array $eTIDs List of transaction IDs for the individual entitlements
* that make up this debit record, if any.
* @return Wallet Self
*/
public function debit(int|Payment $amount, string $description = '', array $eTIDs = []): Wallet
{
return $this->balanceUpdate(Transaction::WALLET_DEBIT, $amount, $description, $eTIDs);
}
/**
* The discount assigned to the wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function discount()
{
return $this->belongsTo(Discount::class, 'discount_id', 'id');
}
/**
* Entitlements billed to this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany(Entitlement::class);
}
/**
* Calculate the expected charges to this wallet.
*
* @return int
*/
public function expectedCharges()
{
return $this->chargeEntitlements(false);
}
/**
* Return the exact, numeric version of the discount to be applied.
*
* Ranges from 0 - 100.
*
* @return int
*/
public function getDiscount()
{
return $this->discount ? $this->discount->discount : 0;
}
/**
* The actual discount rate for use in multiplication
*
* Ranges from 0.00 to 1.00.
*/
public function getDiscountRate()
{
return (100 - $this->getDiscount()) / 100;
}
/**
* Check if the specified user is a controller to this wallet.
*
* @param \App\User $user The user object.
*
* @return bool True if the user is one of the wallet controllers (including user), False otherwise
*/
public function isController(User $user): bool
{
return $user->id == $this->user_id || $this->controllers->contains($user);
}
/**
* A helper to display human-readable amount of money using
* the wallet currency and specified locale.
*
* @param int $amount A amount of money (in cents)
* @param string $locale A locale for the output
*
* @return string String representation, e.g. "9.99 CHF"
*/
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);
}
/**
* The owner of the wallet -- the wallet is in his/her back pocket.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function owner()
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
/**
* Payments on this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function payments()
{
return $this->hasMany(Payment::class);
}
/**
* Add a penalty to this wallet's balance.
*
* @param int|\App\Payment $amount The amount of penalty (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public function penalty(int|Payment $amount, string $description = ''): Wallet
{
return $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description);
}
/**
* Plan of the wallet.
*
* @return ?\App\Plan
*/
public function plan()
{
$planId = $this->owner->getSetting('plan_id');
return $planId ? Plan::find($planId) : null;
}
/**
* Remove a controller from this wallet.
*
* @param \App\User $user The user to remove as a controller from this wallet.
*
* @return void
*/
public function removeController(User $user)
{
if ($this->controllers->contains($user)) {
$this->controllers()->detach($user);
}
}
/**
* Refund an amount of pecunia from this wallet's balance.
*
* @param int|\App\Payment $amount The amount of pecunia to refund (in cents) or Payment object
* @param string $description The transaction description
*
* @return Wallet Self
*/
public function refund($amount, string $description = ''): Wallet
{
return $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description);
}
/**
* Get the VAT rate for the wallet owner country.
*
* @param ?\DateTime $start Get the rate valid for the specified date-time,
* without it the current rate will be returned (if exists).
*
* @return ?\App\VatRate VAT rate
*/
public function vatRate(\DateTime $start = null): ?VatRate
{
$owner = $this->owner;
// Make it working with deleted accounts too
if (!$owner) {
$owner = $this->owner()->withTrashed()->first();
}
$country = $owner->getSetting('country');
if (!$country) {
return null;
}
return VatRate::where('country', $country)
->where('start', '<=', ($start ?: now())->format('Y-m-d h:i:s'))
->orderByDesc('start')
->limit(1)
->first();
}
/**
* Retrieve the transactions against this wallet.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function transactions()
{
return Transaction::where('object_id', $this->id)->where('object_type', Wallet::class);
}
/**
* Returns trial related information.
*
* @return ?array Plan ID, plan SKUs, trial end date, number of free months (planId, skus, end, months)
*/
public function trialInfo(): ?array
{
$plan = $this->plan();
$freeMonths = $plan ? $plan->free_months : 0;
$trialEnd = $freeMonths ? $this->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null;
if ($trialEnd) {
// Get all SKUs assigned to the plan (they are free in trial)
// TODO: We could store the list of plan's SKUs in the wallet settings, for two reasons:
// - performance
// - if we change plan definition at some point in time, the old users would use
// the old definition, instead of the current one
// TODO: The same for plan's free_months value
$trialSkus = \App\Sku::select('id')
->whereIn('id', function ($query) use ($plan) {
$query->select('sku_id')
->from('package_skus')
->whereIn('package_id', function ($query) use ($plan) {
$query->select('package_id')
->from('plan_packages')
->where('plan_id', $plan->id);
});
})
->whereNot('title', 'storage')
->pluck('id')
->all();
return [
'end' => $trialEnd,
'skus' => $trialSkus,
'planId' => $plan->id,
'months' => $freeMonths,
];
}
return null;
}
/**
* Force-update entitlements' updated_at, charge if needed.
*
* @param bool $withCost When enabled the cost will be charged
*
* @return int Charged amount in cents
*/
public function updateEntitlements($withCost = true): int
{
$charges = 0;
$discount = $this->getDiscountRate();
$now = Carbon::now();
DB::beginTransaction();
// used to parent individual entitlement billings to the wallet debit.
$entitlementTransactions = [];
foreach ($this->entitlements()->get() as $entitlement) {
$cost = 0;
$diffInDays = $entitlement->updated_at->diffInDays($now);
// This entitlement has been created less than or equal to 14 days ago (this is at
// maximum the fourteenth 24-hour period).
if ($entitlement->created_at > Carbon::now()->subDays(14)) {
// $cost=0
} elseif ($withCost && $diffInDays > 0) {
// The price per day is based on the number of days in the last month
// or the current month if the period does not overlap with the previous month
// FIXME: This really should be simplified to constant $daysInMonth=30
if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) {
$daysInMonth = $now->daysInMonth;
} else {
$daysInMonth = \App\Utils::daysInLastMonth();
}
$pricePerDay = $entitlement->cost / $daysInMonth;
$cost = (int) (round($pricePerDay * $discount * $diffInDays, 0));
}
if ($diffInDays > 0) {
$entitlement->updated_at = $entitlement->updated_at->setDateFrom($now);
$entitlement->save();
}
if ($cost == 0) {
continue;
}
$charges += $cost;
// FIXME: Shouldn't we store also cost=0 transactions (to have the full history)?
$entitlementTransactions[] = $entitlement->createTransaction(
Transaction::ENTITLEMENT_BILLED,
$cost
);
}
if ($charges > 0) {
$this->debit($charges, '', $entitlementTransactions);
}
DB::commit();
return $charges;
}
/**
* Update the wallet balance, and create a transaction record
*/
protected function balanceUpdate(string $type, int|Payment $amount, $description = null, array $eTIDs = [])
{
if ($amount instanceof Payment) {
$amount = $amount->credit_amount;
}
if ($amount === 0) {
return $this;
}
if (in_array($type, [Transaction::WALLET_CREDIT, Transaction::WALLET_AWARD])) {
$amount = abs($amount);
} else {
$amount = abs($amount) * -1;
}
$this->balance += $amount;
$this->save();
$transaction = Transaction::create([
'user_email' => \App\Utils::userEmailOrNull(),
'object_id' => $this->id,
'object_type' => Wallet::class,
'type' => $type,
'amount' => $amount,
'description' => $description,
]);
if (!empty($eTIDs)) {
Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
}
return $this;
}
}
diff --git a/src/resources/js/app.js b/src/resources/js/app.js
index 114e1379..e2f2dc96 100644
--- a/src/resources/js/app.js
+++ b/src/resources/js/app.js
@@ -1,505 +1,505 @@
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap')
import AppComponent from '../vue/App'
import MenuComponent from '../vue/Widgets/Menu'
import SupportForm from '../vue/Widgets/SupportForm'
import { loadLangAsync, i18n } from './locale'
import { clearFormValidation, pick, startLoading, stopLoading } from './utils'
const routerState = {
afterLogin: null,
isLoggedIn: !!localStorage.getItem('token'),
isLocked: false
}
let loadingRoute
// Note: This has to be before the app is created
// Note: You cannot use app inside of the function
window.router.beforeEach((to, from, next) => {
// check if the route requires authentication and user is not logged in
if (to.meta.requiresAuth && !routerState.isLoggedIn) {
// remember the original request, to use after login
routerState.afterLogin = to;
// 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' })
+ if (routerState.isLocked && to.meta.requiresAuth && !['login', 'payment-status'].includes(to.name)) {
+ // redirect to the payment-status page
+ next({ name: 'payment-status' })
return
}
if (to.meta.loading) {
startLoading()
loadingRoute = to.name
}
next()
})
window.router.afterEach((to, from) => {
if (to.name && loadingRoute === to.name) {
stopLoading()
loadingRoute = null
}
// When changing a page remove old:
// - error page
// - modal backdrop
$('#error-page,.modal-backdrop.show').remove()
$('body').css('padding', 0) // remove padding added by unclosed modal
// Close the mobile menu
if ($('#header-menu .navbar-collapse.show').length) {
$('#header-menu .navbar-toggler').click();
}
})
const app = new Vue({
components: {
AppComponent,
MenuComponent,
},
i18n,
router: window.router,
data() {
return {
authInfo: null,
isUser: !window.isAdmin && !window.isReseller,
appName: window.config['app.name'],
appUrl: window.config['app.url'],
themeDir: '/themes/' + window.config['app.theme']
}
},
methods: {
clearFormValidation,
countriesText(list) {
if (list && list.length) {
let result = []
list.forEach(code => {
let country = window.config.countries[code]
if (country) {
result.push(country[1])
} else {
console.warn(`Unknown country code: ${code}`)
}
})
return result.join(', ')
}
return this.$t('form.norestrictions')
},
hasPermission(type) {
const key = 'enable' + type.charAt(0).toUpperCase() + type.slice(1)
return !!(this.authInfo && this.authInfo.statusInfo[key])
},
hasRoute(name) {
return this.$router.resolve({ name: name }).resolved.matched.length > 0
},
hasSKU(name) {
return this.authInfo.statusInfo.skus && this.authInfo.statusInfo.skus.indexOf(name) != -1
},
isController(wallet_id) {
if (wallet_id && this.authInfo) {
let i
for (i = 0; i < this.authInfo.wallets.length; i++) {
if (wallet_id == this.authInfo.wallets[i].id) {
return true
}
}
for (i = 0; i < this.authInfo.accounts.length; i++) {
if (wallet_id == this.authInfo.accounts[i].id) {
return true
}
}
}
return false
},
isDegraded() {
return this.authInfo && this.authInfo.isAccountDegraded
},
// Set user state to "logged in"
loginUser(response, dashboard, update) {
if (!update) {
routerState.isLoggedIn = true
this.authInfo = null
}
localStorage.setItem('token', response.access_token)
localStorage.setItem('refreshToken', response.refresh_token)
if (response.email) {
this.authInfo = response
}
routerState.isLocked = this.authInfo && this.authInfo.isLocked
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
// Refresh the token before it expires
let timeout = response.expires_in || 0
// We'll refresh 60 seconds before the token expires
if (timeout > 60) {
timeout -= 60
}
// TODO: We probably should try a few times in case of an error
// TODO: We probably should prevent axios from doing any requests
// while the token is being refreshed
this.refreshTimeout = setTimeout(() => {
axios.post('api/auth/refresh', { refresh_token: localStorage.getItem('refreshToken') }).then(response => {
this.loginUser(response.data, false, true)
})
}, timeout * 1000)
},
// Set user state to "not logged in"
logoutUser(redirect) {
routerState.isLoggedIn = true
this.authInfo = null
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
if (redirect !== false) {
this.$router.push({ name: 'login' })
}
clearTimeout(this.refreshTimeout)
},
logo(mode) {
let src = this.appUrl + this.themeDir + '/images/logo_' + (mode || 'header') + '.png'
return `<img src="${src}" alt="${this.appName}">`
},
pick,
startLoading,
stopLoading,
errorPage(code, msg, hint) {
// Until https://github.com/vuejs/vue-router/issues/977 is implemented
// we can't really use router to display error page as it has two side
// effects: it changes the URL and adds the error page to browser history.
// For now we'll be replacing current view with error page "manually".
if (!msg) msg = this.$te('error.' + code) ? this.$t('error.' + code) : this.$t('error.unknown')
if (!hint) hint = ''
const error_page = '<div id="error-page" class="error-page">'
+ `<div class="code">${code}</div><div class="message">${msg}</div><div class="hint">${hint}</div>`
+ '</div>'
$('#error-page').remove()
$('#app').append(error_page)
app.updateBodyClass('error')
},
errorHandler(error) {
stopLoading()
const status = error.response ? error.response.status : 500
const message = error.response ? error.response.statusText : ''
if (status == 401) {
// Remember requested route to come back to it after log in
if (this.$route.meta.requiresAuth) {
routerState.afterLogin = this.$route
this.logoutUser()
} else {
this.logoutUser(false)
}
} else {
if (!error.response) {
console.error(error)
}
this.errorPage(status, message)
}
},
price(price, currency) {
if (!currency) {
currency = 'CHF'
} else {
currency = currency.toUpperCase()
}
let args = { style: 'currency', currency }
if (currency == 'BTC') {
args.minimumFractionDigits = 6
args.maximumFractionDigits = 9
}
// TODO: Set locale argument according to the currently used locale
return ((price || 0) / 100).toLocaleString('de-DE', args)
},
priceLabel(cost, discount, currency) {
let index = ''
if (discount) {
cost = Math.floor(cost * ((100 - discount) / 100))
index = '\u00B9'
}
return this.price(cost, currency) + '/' + this.$t('wallet.month') + index
},
clickRecord(event) {
if (!/^(a|button|svg|path)$/i.test(event.target.nodeName)) {
$(event.target).closest('tr').find('a').trigger('click')
}
},
pageName(path) {
let page = this.$route.path
// check if it is a "menu page", find the page name
// otherwise we'll use the real path as page name
window.config.menu.every(item => {
if (item.location == page && item.page) {
page = item.page
return false
}
})
page = page.replace(/^\//, '')
return page ? page : '404'
},
supportDialog(container) {
let dialog = $('#support-dialog')[0]
if (!dialog) {
// FIXME: Find a nicer way of doing this
SupportForm.i18n = i18n
let form = new Vue(SupportForm)
form.$mount($('<div>').appendTo(container)[0])
form.$root = this
form.$toast = this.$toast
dialog = form.$el
}
dialog.__vue__.show()
},
statusClass(obj) {
if (obj.isDeleted) {
return 'text-muted'
}
if (obj.isDegraded || obj.isAccountDegraded || obj.isSuspended) {
return 'text-warning'
}
if (!obj.isReady) {
return 'text-danger'
}
return 'text-success'
},
statusText(obj) {
if (obj.isDeleted) {
return this.$t('status.deleted')
}
if (obj.isDegraded || obj.isAccountDegraded) {
return this.$t('status.degraded')
}
if (obj.isSuspended) {
return this.$t('status.suspended')
}
if (!obj.isReady) {
return this.$t('status.notready')
}
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]
if (!wallet) {
wallet = this.authInfo.wallets[0]
}
if (wallet) {
object.currency = wallet.currency
if (wallet.discount) {
object.discount = wallet.discount
object.discount_description = wallet.discount_description
}
}
},
updateBodyClass(name) {
// Add 'class' attribute to the body, different for each page
// so, we can apply page-specific styles
document.body.className = 'page-' + (name || this.pageName()).replace(/\/.*$/, '')
}
}
})
// Fetch the locale file and the start the app
loadLangAsync().then(() => app.$mount('#app'))
// Add a axios request interceptor
axios.interceptors.request.use(
config => {
// This is the only way I found to change configuration options
// on a running application. We need this for browser testing.
config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider
// Set the Authorization header. Note that some request might force
// empty Authorization header therefore we check if the header is already set,
// not whether it's empty
const token = localStorage.getItem('token')
if (token && !('Authorization' in config.headers)) {
config.headers.Authorization = 'Bearer ' + token
}
let loader = config.loader
if (loader) {
startLoading(loader)
}
return config
},
error => {
// Do something with request error
return Promise.reject(error)
}
)
// Add a axios response interceptor for general/validation error handler
axios.interceptors.response.use(
response => {
if (response.config.onFinish) {
response.config.onFinish()
}
let loader = response.config.loader
if (loader) {
stopLoading(loader)
}
return response
},
error => {
if (error.config && error.config.loader) {
stopLoading(error.config.loader)
}
// Do not display the error in a toast message, pass the error as-is
if (axios.isCancel(error) || (error.config && error.config.ignoreErrors)) {
return Promise.reject(error)
}
if (error.config && error.config.onFinish) {
error.config.onFinish()
}
let error_msg
const status = error.response ? error.response.status : 200
const data = error.response ? error.response.data : {}
if (status == 422 && data.errors) {
error_msg = app.$t('error.form')
const modal = $('div.modal.show')
$(modal.length ? modal : 'form').each((i, form) => {
form = $(form)
$.each(data.errors, (idx, msg) => {
const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx
let input = form.find('#' + input_name)
if (!input.length) {
input = form.find('[name="' + input_name + '"]');
}
if (input.length) {
// Create an error message
// API responses can use a string, array or object
let msg_text = ''
if (typeof(msg) !== 'string') {
$.each(msg, (index, str) => {
msg_text += str + ' '
})
}
else {
msg_text = msg
}
let feedback = $('<div class="invalid-feedback">').text(msg_text)
if (input.is('.list-input')) {
// List input widget
let controls = input.children(':not(:first-child)')
if (!controls.length && typeof msg == 'string') {
// this is an empty list (the main input only)
// and the error message is not an array
input.find('.main-input').addClass('is-invalid')
} else {
controls.each((index, element) => {
if (msg[index]) {
$(element).find('input').addClass('is-invalid')
}
})
}
input.addClass('is-invalid').next('.invalid-feedback').remove()
input.after(feedback)
} else {
// a special case, e.g. the invitation policy widget
if (input.is('select') && input.parent().is('.input-group-select.selected')) {
input = input.next()
}
// Standard form element
input.addClass('is-invalid')
input.parent().find('.invalid-feedback').remove()
input.parent().append(feedback)
}
}
})
form.find('.is-invalid:not(.list-input)').first().focus()
})
}
else if (data.status == 'error') {
error_msg = data.message
}
else {
error_msg = error.request ? error.request.statusText : error.message
}
app.$toast.error(error_msg || app.$t('error.server'))
// Pass the error as-is
return Promise.reject(error)
}
)
diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js
index c8c4e036..857fccbf 100644
--- a/src/resources/js/user/routes.js
+++ b/src/resources/js/user/routes.js
@@ -1,200 +1,207 @@
import LoginComponent from '../../vue/Login'
import LogoutComponent from '../../vue/Logout'
import PageComponent from '../../vue/Page'
import PasswordResetComponent from '../../vue/PasswordReset'
import SignupComponent from '../../vue/Signup'
// Here's a list of lazy-loaded components
// Note: you can pack multiple components into the same chunk, webpackChunkName
// is also used to get a sensible file name instead of numbers
const CompanionAppInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/Info')
const CompanionAppListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp/List')
const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard')
const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info')
const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List')
const DomainInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Domain/Info')
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')
const RoomListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Room/List')
const SettingsComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Settings')
const SharedFolderInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/Info')
const SharedFolderListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/SharedFolder/List')
const UserInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Info')
const UserListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/List')
const UserProfileComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/Profile')
const UserProfileDeleteComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/User/ProfileDelete')
const WalletComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Wallet')
const MeetComponent = () => import(/* webpackChunkName: "../user/meet" */ '../../vue/Meet/Room.vue')
const routes = [
{
path: '/dashboard',
name: 'dashboard',
component: DashboardComponent,
meta: { requiresAuth: true }
},
{
path: '/distlist/:list',
name: 'distlist',
component: DistlistInfoComponent,
meta: { requiresAuth: true, perm: 'distlists' }
},
{
path: '/distlists',
name: 'distlists',
component: DistlistListComponent,
meta: { requiresAuth: true, perm: 'distlists' }
},
{
path: '/companion/:companion',
name: 'companion',
component: CompanionAppInfoComponent,
meta: { requiresAuth: true, perm: 'companionapps' }
},
{
path: '/companions',
name: 'companions',
component: CompanionAppListComponent,
meta: { requiresAuth: true, perm: 'companionapps' }
},
{
path: '/domain/:domain',
name: 'domain',
component: DomainInfoComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/domains',
name: 'domains',
component: DomainListComponent,
meta: { requiresAuth: true, perm: 'domains' }
},
{
path: '/file/:file',
name: 'file',
component: FileInfoComponent,
meta: { requiresAuth: true /*, perm: 'files' */ }
},
{
path: '/files',
name: 'files',
component: FileListComponent,
meta: { requiresAuth: true, perm: 'files' }
},
{
path: '/login',
name: 'login',
component: LoginComponent
},
{
path: '/logout',
name: 'logout',
component: LogoutComponent
},
{
name: 'meet',
path: '/meet/:room',
component: MeetComponent,
meta: { loading: true }
},
{
path: '/password-reset/:code?',
name: 'password-reset',
component: PasswordResetComponent
},
+ {
+ path: '/payment/status',
+ name: 'payment-status',
+ component: PaymentStatusComponent,
+ meta: { requiresAuth: true }
+ },
{
path: '/profile',
name: 'profile',
component: UserProfileComponent,
meta: { requiresAuth: true }
},
{
path: '/profile/delete',
name: 'profile-delete',
component: UserProfileDeleteComponent,
meta: { requiresAuth: true }
},
{
path: '/resource/:resource',
name: 'resource',
component: ResourceInfoComponent,
meta: { requiresAuth: true, perm: 'resources' }
},
{
path: '/resources',
name: 'resources',
component: ResourceListComponent,
meta: { requiresAuth: true, perm: 'resources' }
},
{
path: '/room/:room',
name: 'room',
component: RoomInfoComponent,
meta: { requiresAuth: true, perm: 'rooms' }
},
{
path: '/rooms',
name: 'rooms',
component: RoomListComponent,
meta: { requiresAuth: true, perm: 'rooms' }
},
{
path: '/settings',
name: 'settings',
component: SettingsComponent,
meta: { requiresAuth: true, perm: 'settings' }
},
{
path: '/shared-folder/:folder',
name: 'shared-folder',
component: SharedFolderInfoComponent,
meta: { requiresAuth: true, perm: 'folders' }
},
{
path: '/shared-folders',
name: 'shared-folders',
component: SharedFolderListComponent,
meta: { requiresAuth: true, perm: 'folders' }
},
{
path: '/signup/invite/:param',
name: 'signup-invite',
component: SignupComponent
},
{
path: '/signup/:param?',
alias: '/signup/voucher/:param',
name: 'signup',
component: SignupComponent
},
{
path: '/user/:user',
name: 'user',
component: UserInfoComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/users',
name: 'users',
component: UserListComponent,
meta: { requiresAuth: true, perm: 'users' }
},
{
path: '/wallet',
name: 'wallet',
component: WalletComponent,
meta: { requiresAuth: true, perm: 'wallets' }
},
{
name: '404',
path: '*',
component: PageComponent
}
]
export default routes
diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php
index 97586cd8..c5e6d987 100644
--- a/src/resources/lang/en/app.php
+++ b/src/resources/lang/en/app.php
@@ -1,144 +1,157 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used in the application.
*/
'chart-created' => 'Created',
'chart-deleted' => 'Deleted',
'chart-average' => 'average',
'chart-allusers' => 'All Users - last year',
'chart-discounts' => 'Discounts',
'chart-vouchers' => 'Vouchers',
'chart-income' => 'Income in :currency - last 8 weeks',
'chart-payers' => 'Payers - last year',
'chart-users' => 'Users - last 8 weeks',
'companion-create-success' => 'Companion app has been created.',
'companion-delete-success' => 'Companion app has been removed.',
'mandate-delete-success' => 'The auto-payment has been removed.',
'mandate-update-success' => 'The auto-payment has been updated.',
'planbutton' => 'Choose :plan',
'process-async' => 'Setup process has been pushed. Please wait.',
'process-user-new' => 'Registering a user...',
'process-user-ldap-ready' => 'Creating a user...',
'process-user-imap-ready' => 'Creating a mailbox...',
'process-domain-new' => 'Registering a custom domain...',
'process-domain-ldap-ready' => 'Creating a custom domain...',
'process-domain-verified' => 'Verifying a custom domain...',
'process-domain-confirmed' => 'Verifying an ownership of a custom domain...',
'process-success' => 'Setup process finished successfully.',
'process-error-distlist-ldap-ready' => 'Failed to create a distribution list.',
'process-error-domain-ldap-ready' => 'Failed to create a domain.',
'process-error-domain-verified' => 'Failed to verify a domain.',
'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.',
'process-error-resource-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-resource-ldap-ready' => 'Failed to create a resource.',
'process-error-shared-folder-imap-ready' => 'Failed to verify that a shared folder exists.',
'process-error-shared-folder-ldap-ready' => 'Failed to create a shared folder.',
'process-error-user-ldap-ready' => 'Failed to create a user.',
'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.',
'process-distlist-new' => 'Registering a distribution list...',
'process-distlist-ldap-ready' => 'Creating a distribution list...',
'process-resource-new' => 'Registering a resource...',
'process-resource-imap-ready' => 'Creating a shared folder...',
'process-resource-ldap-ready' => 'Creating a resource...',
'process-shared-folder-new' => 'Registering a shared folder...',
'process-shared-folder-imap-ready' => 'Creating a shared folder...',
'process-shared-folder-ldap-ready' => 'Creating a shared folder...',
'distlist-update-success' => 'Distribution list updated successfully.',
'distlist-create-success' => 'Distribution list created successfully.',
'distlist-delete-success' => 'Distribution list deleted successfully.',
'distlist-suspend-success' => 'Distribution list suspended successfully.',
'distlist-unsuspend-success' => 'Distribution list unsuspended successfully.',
'distlist-setconfig-success' => 'Distribution list settings updated successfully.',
'domain-create-success' => 'Domain created successfully.',
'domain-delete-success' => 'Domain deleted successfully.',
'domain-notempty-error' => 'Unable to delete a domain with assigned users or other objects.',
'domain-verify-success' => 'Domain verified successfully.',
'domain-verify-error' => 'Domain ownership verification failed.',
'domain-suspend-success' => 'Domain suspended successfully.',
'domain-unsuspend-success' => 'Domain unsuspended successfully.',
'domain-setconfig-success' => 'Domain settings updated successfully.',
'file-create-success' => 'File created successfully.',
'file-delete-success' => 'File deleted successfully.',
'file-update-success' => 'File updated successfully.',
'file-permissions-create-success' => 'File permissions created successfully.',
'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.',
'resource-setconfig-success' => 'Resource settings updated successfully.',
'room-update-success' => 'Room updated successfully.',
'room-create-success' => 'Room created successfully.',
'room-delete-success' => 'Room deleted successfully.',
'room-setconfig-success' => 'Room configuration updated successfully.',
'room-unsupported-option-error' => 'Invalid room configuration option.',
'shared-folder-update-success' => 'Shared folder updated successfully.',
'shared-folder-create-success' => 'Shared folder created successfully.',
'shared-folder-delete-success' => 'Shared folder deleted successfully.',
'shared-folder-setconfig-success' => 'Shared folder settings updated successfully.',
'user-update-success' => 'User data updated successfully.',
'user-create-success' => 'User created successfully.',
'user-delete-success' => 'User deleted successfully.',
'user-suspend-success' => 'User suspended successfully.',
'user-unsuspend-success' => 'User unsuspended successfully.',
'user-reset-2fa-success' => '2-Factor authentication reset successfully.',
'user-reset-geo-lock-success' => 'Geo-lockin setup reset successfully.',
'user-setconfig-success' => 'User settings updated successfully.',
'user-set-sku-success' => 'The subscription added successfully.',
'user-set-sku-already-exists' => 'The subscription already exists.',
'search-foundxdomains' => ':x domains have been found.',
'search-foundxdistlists' => ':x distribution lists have been found.',
'search-foundxresources' => ':x resources have been found.',
'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.',
'signup-invitation-delete-success' => 'Invitation deleted successfully.',
'signup-invitation-resend-success' => 'Invitation added to the sending queue successfully.',
'support-request-success' => 'Support request submitted successfully.',
'support-request-error' => 'Failed to submit the support request.',
'siteuser' => ':site User',
'wallet-award-success' => 'The bonus has been added to the wallet successfully.',
'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.',
'wallet-update-success' => 'User wallet updated successfully.',
'password-reset-code-delete-success' => 'Password reset code deleted successfully.',
'password-rule-min' => 'Minimum password length: :param characters',
'password-rule-max' => 'Maximum password length: :param characters',
'password-rule-lower' => 'Password contains a lower-case character',
'password-rule-upper' => 'Password contains an upper-case character',
'password-rule-digit' => 'Password contains a digit',
'password-rule-special' => 'Password contains a special character',
'password-rule-last' => 'Password cannot be the same as the last :param passwords',
'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).',
'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.',
'wallet-notice-today' => 'You will run out of credit today, top up your balance now.',
'wallet-notice-trial' => 'You are in your free trial period.',
'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.',
];
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
index 9b84e79c..27ae5fe3 100644
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -1,561 +1,562 @@
<?php
/**
* This file will be converted to a Vue-i18n compatible JSON format on build time
*
* Note: The Laravel localization features do not work here. Vue-i18n rules are different
*/
return [
'app' => [
'faq' => "FAQ",
],
'btn' => [
'add' => "Add",
'accept' => "Accept",
'back' => "Back",
'cancel' => "Cancel",
'close' => "Close",
'continue' => "Continue",
'copy' => "Copy",
'delete' => "Delete",
'deny' => "Deny",
'download' => "Download",
'edit' => "Edit",
'file' => "Choose file...",
'moreinfo' => "More information",
'refresh' => "Refresh",
'reset' => "Reset",
'resend' => "Resend",
'save' => "Save",
'search' => "Search",
'share' => "Share",
'signup' => "Sign Up",
'submit' => "Submit",
'suspend' => "Suspend",
+ 'tryagain' => "Try again",
'unsuspend' => "Unsuspend",
'verify' => "Verify",
],
'companion' => [
'title' => "Companion Apps",
'companion' => "Companion App",
'name' => "Name",
'create' => "Pair new device",
'create-recovery-device' => "Prepare recovery code",
'description' => "Use the Companion App on your mobile phone as multi-factor authentication device.",
'download-description' => "You may download the Companion App for Android here: "
. "<a href=\"{href}\">Download</a>",
'description-detailed' => "Here is how this works: " .
"Pairing a device will automatically enable multi-factor autentication for all login attempts. " .
"This includes not only the Cockpit, but also logins via Webmail, IMAP, SMPT, DAV and ActiveSync. " .
"Any authentication attempt will result in a notification on your device, " .
"that you can use to confirm if it was you, or deny otherwise. " .
"Once confirmed, the same username + IP address combination will be whitelisted for 8 hours. " .
"Unpair all your active devices to disable multi-factor authentication again.",
'description-warning' => "Warning: Loosing access to all your multi-factor authentication devices, " .
"will permanently lock you out of your account with no course for recovery. " .
"Always make sure you have a recovery QR-Code printed to pair a recovery device.",
'new' => "Pair new device",
'recovery' => "Prepare recovery device",
'paired' => "Paired devices",
'print' => "Print for backup",
'pairing-instructions' => "Pair your device using the following QR-Code.",
'recovery-device' => "Recovery Device",
'deviceid' => "Device ID",
'list-empty' => "There are currently no devices",
'delete' => "Delete/Unpair",
'delete-companion' => "Delete/Unpair",
'delete-text' => "You are about to delete this entry and unpair any paired companion app. " .
"This cannot be undone, but you can pair the device again.",
'pairing-successful' => "Your companion app is paired and ready to be used " .
"as a multi-factor authentication device.",
],
'dashboard' => [
'beta' => "beta",
'distlists' => "Distribution lists",
'chat' => "Video chat",
'companion' => "Companion app",
'domains' => "Domains",
'files' => "Files",
'invitations' => "Invitations",
'profile' => "Your profile",
'resources' => "Resources",
'settings' => "Settings",
'shared-folders' => "Shared folders",
'users' => "User accounts",
'wallet' => "Wallet",
'webmail' => "Webmail",
'stats' => "Stats",
],
'distlist' => [
'list-title' => "Distribution list | Distribution lists",
'create' => "Create list",
'delete' => "Delete list",
'email' => "Email",
'list-empty' => "There are no distribution lists in this account.",
'name' => "Name",
'new' => "New distribution list",
'recipients' => "Recipients",
'sender-policy' => "Sender Access List",
'sender-policy-text' => "With this list you can specify who can send mail to the distribution list."
. " You can put a complete email address (jane@kolab.org), domain (kolab.org) or suffix (.org) that the sender email address is compared to."
. " If the list is empty, mail from anyone is allowed.",
],
'domain' => [
'delete' => "Delete domain",
'delete-domain' => "Delete {domain}",
'delete-text' => "Do you really want to delete this domain permanently?"
. " This is only possible if there are no users, aliases or other objects in this domain."
. " Please note that this action cannot be undone.",
'dns-verify' => "Domain DNS verification sample:",
'dns-config' => "Domain DNS configuration sample:",
'list-empty' => "There are no domains in this account.",
'namespace' => "Namespace",
'spf-whitelist' => "SPF Whitelist",
'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, "
. "which systems are allowed to send emails with an envelope sender address within said domain.",
'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: <var>.ess.barracuda.com</var>.",
'verify' => "Domain verification",
'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.",
'verify-dns' => "The domain <b>must have one of the following entries</b> in DNS:",
'verify-dns-txt' => "TXT entry with value:",
'verify-dns-cname' => "or CNAME entry:",
'verify-outro' => "When this is done press the button below to start the verification.",
'verify-sample' => "Here's a sample zone file for your domain:",
'config' => "Domain configuration",
'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.",
'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:",
'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.",
'create' => "Create domain",
'new' => "New domain",
],
'error' => [
'400' => "Bad request",
'401' => "Unauthorized",
'403' => "Access denied",
'404' => "Not found",
'405' => "Method not allowed",
'500' => "Internal server error",
'unknown' => "Unknown Error",
'server' => "Server Error",
'form' => "Form validation error",
],
'file' => [
'create' => "Create file",
'delete' => "Delete file",
'list-empty' => "There are no files in this account.",
'mimetype' => "Mimetype",
'mtime' => "Modified",
'new' => "New file",
'search' => "File name",
'sharing' => "Sharing",
'sharing-links-text' => "You can share the file with other users by giving them read-only access "
. "to the file via a unique link.",
],
'form' => [
'acl' => "Access rights",
'acl-full' => "All",
'acl-read-only' => "Read-only",
'acl-read-write' => "Read-write",
'amount' => "Amount",
'anyone' => "Anyone",
'code' => "Confirmation Code",
'config' => "Configuration",
'companion' => "Companion App",
'date' => "Date",
'description' => "Description",
'details' => "Details",
'disabled' => "disabled",
'domain' => "Domain",
'email' => "Email Address",
'emails' => "Email Addresses",
'enabled' => "enabled",
'firstname' => "First Name",
'general' => "General",
'geolocation' => "Your current location: {location}",
'lastname' => "Last Name",
'name' => "Name",
'months' => "months",
'none' => "none",
'norestrictions' => "No restrictions",
'or' => "or",
'password' => "Password",
'password-confirm' => "Confirm Password",
'phone' => "Phone",
'selectcountries' => "Select countries",
'settings' => "Settings",
'shared-folder' => "Shared Folder",
'size' => "Size",
'status' => "Status",
'subscriptions' => "Subscriptions",
'surname' => "Surname",
'type' => "Type",
'unknown' => "unknown",
'user' => "User",
'primary-email' => "Primary Email",
'id' => "ID",
'created' => "Created",
'deleted' => "Deleted",
],
'invitation' => [
'create' => "Create invite(s)",
'create-title' => "Invite for a signup",
'create-email' => "Enter an email address of the person you want to invite.",
'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.",
'list-empty' => "There are no invitations in the database.",
'title' => "Signup invitations",
'search' => "Email address or domain",
'send' => "Send invite(s)",
'status-completed' => "User signed up",
'status-failed' => "Sending failed",
'status-sent' => "Sent",
'status-new' => "Not sent yet",
],
'lang' => [
'en' => "English",
'de' => "German",
'fr' => "French",
'it' => "Italian",
],
'login' => [
'2fa' => "Second factor code",
'2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.",
'forgot_password' => "Forgot password?",
'header' => "Please sign in",
'sign_in' => "Sign in",
'signing_in' => "Signing in...",
'webmail' => "Webmail"
],
'meet' => [
// Room options dialog
'options' => "Room options",
'password' => "Password",
'password-none' => "none",
'password-clear' => "Clear password",
'password-set' => "Set password",
'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.",
'lock' => "Locked room",
'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.",
'nomedia' => "Subscribers only",
'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)."
. " Moderators will be able to promote them to publishers throughout the session.",
// Room menu
'partcnt' => "Number of participants",
'menu-audio-mute' => "Mute audio",
'menu-audio-unmute' => "Unmute audio",
'menu-video-mute' => "Mute video",
'menu-video-unmute' => "Unmute video",
'menu-screen' => "Share screen",
'menu-hand-lower' => "Lower hand",
'menu-hand-raise' => "Raise hand",
'menu-channel' => "Interpreted language channel",
'menu-chat' => "Chat",
'menu-fullscreen' => "Full screen",
'menu-fullscreen-exit' => "Exit full screen",
'menu-leave' => "Leave session",
// Room setup screen
'setup-title' => "Set up your session",
'mic' => "Microphone",
'cam' => "Camera",
'nick' => "Nickname",
'nick-placeholder' => "Your name",
'join' => "JOIN",
'joinnow' => "JOIN NOW",
'imaowner' => "I'm the owner",
// Room
'qa' => "Q & A",
'leave-title' => "Room closed",
'leave-body' => "The session has been closed by the room owner.",
'media-title' => "Media setup",
'join-request' => "Join request",
'join-requested' => "{user} requested to join.",
// Status messages
'status-init' => "Checking the room...",
'status-323' => "The room is closed. Please, wait for the owner to start the session.",
'status-324' => "The room is closed. It will be open for others after you join.",
'status-325' => "The room is ready. Please, provide a valid password.",
'status-326' => "The room is locked. Please, enter your name and try again.",
'status-327' => "Waiting for permission to join the room.",
'status-404' => "The room does not exist.",
'status-429' => "Too many requests. Please, wait.",
'status-500' => "Failed to connect to the room. Server error.",
// Other menus
'media-setup' => "Media setup",
'perm' => "Permissions",
'perm-av' => "Audio &amp; Video publishing",
'perm-mod' => "Moderation",
'lang-int' => "Language interpreter",
'menu-options' => "Options",
],
'menu' => [
'cockpit' => "Cockpit",
'login' => "Login",
'logout' => "Logout",
'signup' => "Signup",
'toggle' => "Toggle navigation",
],
'msg' => [
'initializing' => "Initializing...",
'loading' => "Loading...",
'loading-failed' => "Failed to load data.",
'notfound' => "Resource not found.",
'info' => "Information",
'error' => "Error",
'uploading' => "Uploading...",
'warning' => "Warning",
'success' => "Success",
],
'nav' => [
'more' => "Load more",
'step' => "Step {i}/{n}",
],
'password' => [
'link-invalid' => "The password reset code is expired or invalid.",
'reset' => "Password Reset",
'reset-step1' => "Enter your email address to reset your password.",
'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.",
'reset-step2' => "We sent out a confirmation code to your external email address."
. " Enter the code we sent you, or click the link in the message.",
],
'resource' => [
'create' => "Create resource",
'delete' => "Delete resource",
'invitation-policy' => "Invitation policy",
'invitation-policy-text' => "Event invitations for a resource are normally accepted automatically"
. " if there is no conflicting event on the requested time slot. Invitation policy allows"
. " for rejecting such requests or to require a manual acceptance from a specified user.",
'ipolicy-manual' => "Manual (tentative)",
'ipolicy-accept' => "Accept",
'ipolicy-reject' => "Reject",
'list-title' => "Resource | Resources",
'list-empty' => "There are no resources in this account.",
'new' => "New resource",
],
'room' => [
'create' => "Create room",
'delete' => "Delete room",
'copy-location' => "Copy room location",
'description-hint' => "This is an optional short description for the room, so you can find it more easily on the list.",
'goto' => "Enter the room",
'list-empty' => "There are no conference rooms in this account.",
'list-empty-nocontroller' => "Do you need a room? Ask your account owner to create one and share it with you.",
'list-title' => "Voice & video conferencing rooms",
'moderators' => "Moderators",
'moderators-text' => "You can share your room with other users. They will become the room moderators with all moderator powers and ability to open the room without your presence.",
'new' => "New room",
'new-hint' => "We'll generate a unique name for the room that will then allow you to access the room.",
'title' => "Room: {name}",
'url' => "You can access the room at the URL below. Use this URL to invite people to join you. This room is only open when you (or another room moderator) is in attendance.",
],
'settings' => [
'password-policy' => "Password Policy",
'password-retention' => "Password Retention",
'password-max-age' => "Require a password change every",
],
'shf' => [
'aliases-none' => "This shared folder has no email aliases.",
'create' => "Create folder",
'delete' => "Delete folder",
'acl-text' => "Defines user permissions to access the shared folder.",
'list-title' => "Shared folder | Shared folders",
'list-empty' => "There are no shared folders in this account.",
'new' => "New shared folder",
'type-mail' => "Mail",
'type-event' => "Calendar",
'type-contact' => "Address Book",
'type-task' => "Tasks",
'type-note' => "Notes",
'type-file' => "Files",
],
'signup' => [
'email' => "Existing Email Address",
'login' => "Login",
'title' => "Sign Up",
'step1' => "Sign up to start your free month.",
'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.",
'step3' => "Create your {app} identity (you can choose additional addresses later).",
'token' => "Signup authorization token",
'voucher' => "Voucher Code",
],
'status' => [
'prepare-account' => "We are preparing your account.",
'prepare-domain' => "We are preparing the domain.",
'prepare-distlist' => "We are preparing the distribution list.",
'prepare-resource' => "We are preparing the resource.",
'prepare-shared-folder' => "We are preparing the shared folder.",
'prepare-user' => "We are preparing the user account.",
'prepare-hint' => "Some features may be missing or readonly at the moment.",
'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.",
'ready-account' => "Your account is almost ready.",
'ready-domain' => "The domain is almost ready.",
'ready-distlist' => "The distribution list is almost ready.",
'ready-resource' => "The resource is almost ready.",
'ready-shared-folder' => "The shared-folder is almost ready.",
'ready-user' => "The user account is almost ready.",
'verify' => "Verify your domain to finish the setup process.",
'verify-domain' => "Verify domain",
'degraded' => "Degraded",
'deleted' => "Deleted",
'restricted' => "Restricted",
'suspended' => "Suspended",
'notready' => "Not Ready",
'active' => "Active",
],
'support' => [
'title' => "Contact Support",
'id' => "Customer number or email address you have with us",
'id-pl' => "e.g. 12345678 or the affected email address",
'id-hint' => "Leave blank if you are not a customer yet",
'name' => "Name",
'name-pl' => "how we should call you in our reply",
'email' => "Working email address",
'email-pl' => "make sure we can reach you at this address",
'summary' => "Issue Summary",
'summary-pl' => "one sentence that summarizes your issue",
'expl' => "Issue Explanation",
],
'user' => [
'2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.",
'2fa-hint2' => "Please, make sure to confirm the user identity properly.",
'add-beta' => "Enable beta program",
'address' => "Address",
'aliases' => "Aliases",
'aliases-none' => "This user has no email aliases.",
'add-bonus' => "Add bonus",
'add-bonus-title' => "Add a bonus to the wallet",
'add-penalty' => "Add penalty",
'add-penalty-title' => "Add a penalty to the wallet",
'auto-payment' => "Auto-payment",
'auto-payment-text' => "Fill up by <b>{amount}</b> when under <b>{balance}</b> using {method}",
'country' => "Country",
'create' => "Create user",
'custno' => "Customer No.",
'degraded-warning' => "The account is degraded. Some features have been disabled.",
'degraded-hint' => "Please, make a payment.",
'delete' => "Delete user",
'delete-account' => "Delete this account?",
'delete-email' => "Delete {email}",
'delete-text' => "Do you really want to delete this user permanently?"
. " This will delete all account data and withdraw the permission to access the email account."
. " Please note that this action cannot be undone.",
'discount' => "Discount",
'discount-hint' => "applied discount",
'discount-title' => "Account discount",
'distlists' => "Distribution lists",
'domains' => "Domains",
'ext-email' => "External Email",
'email-aliases' => "Email Aliases",
'finances' => "Finances",
'geolimit' => "Geo-lockin",
'geolimit-text' => "Defines a list of locations that are allowed for logon. You will not be able to login from a country that is not listed here.",
'greylisting' => "Greylisting",
'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender "
. "is temporarily rejected. The originating server should try again after a delay. "
. "This time the email will be accepted. Spammers usually do not reattempt mail delivery.",
'imapproxy' => "IMAP proxy",
'imapproxy-text' => "Enables IMAP proxy that filters out non-mail groupware folders, so your IMAP clients do not see them.",
'list-title' => "User accounts",
'list-empty' => "There are no users in this account.",
'managed-by' => "Managed by",
'new' => "New user account",
'org' => "Organization",
'package' => "Package",
'pass-input' => "Enter password",
'pass-link' => "Set via link",
'pass-link-label' => "Link:",
'pass-link-hint' => "Press Submit to activate the link",
'passwordpolicy' => "Password Policy",
'price' => "Price",
'profile-title' => "Your profile",
'profile-delete' => "Delete account",
'profile-delete-title' => "Delete this account?",
'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.",
'profile-delete-warning' => "This operation is irreversible",
'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.",
'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. "
. "The best tool for improvement is feedback from users, and we would like to ask "
. "for a few words about your reasons for leaving our service. Please send your feedback to <a href=\"{href}\">{email}</a>.",
'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.",
'reset-2fa' => "Reset 2-Factor Auth",
'reset-2fa-title' => "2-Factor Authentication Reset",
'resources' => "Resources",
'title' => "User account",
'search' => "User email address or name",
'search-pl' => "User ID, email or domain",
'skureq' => "{sku} requires {list}.",
'subscription' => "Subscription",
'subscriptions-none' => "This user has no subscriptions.",
'users' => "Users",
],
'wallet' => [
'add-credit' => "Add credit",
'auto-payment-cancel' => "Cancel auto-payment",
'auto-payment-change' => "Change auto-payment",
'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.",
'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose."
. " You can cancel or change the auto-payment option at any time.",
'auto-payment-setup' => "Set up auto-payment",
'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.",
'auto-payment-info' => "Auto-payment is <b>set</b> to fill up your account by <b>{amount}</b> every time your account balance gets under <b>{balance}</b>.",
'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.",
'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.",
'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.",
'auto-payment-update' => "Update auto-payment",
'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.",
'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " We will then create a charge on Coinbase for the specified amount that you can pay using Bitcoin.",
'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}."
. " 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.",
'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.",
'payment-method' => "Method of payment: {method}",
'payment-warning' => "You will be charged for {price}.",
'pending-payments' => "Pending Payments",
'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.",
'pending-payments-none' => "There are no pending payments for this account.",
'receipts' => "Receipts",
'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
'title' => "Account balance",
'top-up' => "Top up your wallet",
'transactions' => "Transactions",
'transactions-none' => "There are no transactions for this account.",
'when-below' => "when account balance is below",
],
];
diff --git a/src/resources/vue/Payment/Status.vue b/src/resources/vue/Payment/Status.vue
new file mode 100644
index 00000000..d27c85bc
--- /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
index 818ce41e..d36ed032 100644
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -1,373 +1,412 @@
<template>
<div class="container">
<div id="step0" v-if="!invitation">
<div class="plan-selector row row-cols-sm-2 g-3">
<div v-for="item in plans" :key="item.id" :id="'plan-' + item.title">
<div :class="'card bg-light plan-' + item.title">
<div class="card-header plan-header">
<div class="plan-ico text-center">
<svg-icon :icon="plan_icons[item.title] || 'user'"></svg-icon>
</div>
</div>
<div class="card-body text-center">
<btn class="btn-primary" :data-title="item.title" @click="selectPlan(item.title)" v-html="item.button"></btn>
<div class="plan-description text-start mt-3" v-html="item.description"></div>
</div>
</div>
</div>
</div>
</div>
<div class="card d-none" id="step1" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 1, n: steps }) }}</h4>
<p class="card-text">
{{ $t('signup.step1') }}
</p>
<form @submit.prevent="submitStep1" data-validation-prefix="signup_">
<div class="mb-3">
<div class="input-group">
<input type="text" class="form-control" id="signup_first_name" :placeholder="$t('form.firstname')" autofocus v-model="first_name">
<input type="text" class="form-control rounded-end" id="signup_last_name" :placeholder="$t('form.surname')" v-model="last_name">
</div>
</div>
<div v-if="mode == 'token'" class="mb-3">
<label for="signup_token" class="visually-hidden">{{ $t('signup.token') }}</label>
<input type="text" class="form-control" id="signup_token" :placeholder="$t('signup.token')" required v-model="token">
</div>
<div v-else class="mb-3">
<label for="signup_email" class="visually-hidden">{{ $t('signup.email') }}</label>
<input type="text" class="form-control" id="signup_email" :placeholder="$t('signup.email')" required v-model="email">
</div>
<btn class="btn-secondary" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary ms-2" type="submit" icon="check">{{ $t('btn.continue') }}</btn>
</form>
</div>
</div>
<div class="card d-none" id="step2" v-if="!invitation">
<div class="card-body">
<h4 class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: 2, n: steps }) }}</h4>
<p class="card-text">
{{ $t('signup.step2') }}
</p>
<form @submit.prevent="submitStep2" data-validation-prefix="signup_">
<div class="mb-3">
<label for="signup_short_code" class="visually-hidden">{{ $t('form.code') }}</label>
<input type="text" class="form-control" id="signup_short_code" :placeholder="$t('form.code')" required v-model="short_code">
</div>
<btn class="btn-secondary" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary ms-2" type="submit" icon="check">{{ $t('btn.continue') }}</btn>
<input type="hidden" id="signup_code" v-model="code" />
</form>
</div>
</div>
<div class="card d-none" id="step3">
<div class="card-body">
<h4 v-if="!invitation && steps > 1" class="card-title">{{ $t('signup.title') }} - {{ $t('nav.step', { i: steps, n: steps }) }}</h4>
<p class="card-text">
{{ $t('signup.step3', { app: $root.appName }) }}
</p>
<form @submit.prevent="submitStep3" data-validation-prefix="signup_">
<div class="mb-3" v-if="invitation">
<div class="input-group">
<input type="text" class="form-control" id="signup_first_name" :placeholder="$t('form.firstname')" autofocus v-model="first_name">
<input type="text" class="form-control rounded-end" id="signup_last_name" :placeholder="$t('form.surname')" v-model="last_name">
</div>
</div>
<div class="mb-3">
<label for="signup_login" class="visually-hidden"></label>
<div class="input-group">
<input type="text" class="form-control" id="signup_login" required v-model="login" :placeholder="$t('signup.login')">
<span class="input-group-text">@</span>
<input v-if="is_domain" type="text" class="form-control rounded-end" id="signup_domain" required v-model="domain" :placeholder="$t('form.domain')">
<select v-else class="form-select rounded-end" id="signup_domain" required v-model="domain">
<option v-for="_domain in domains" :key="_domain" :value="_domain">{{ _domain }}</option>
</select>
</div>
</div>
<password-input class="mb-3" v-model="pass"></password-input>
<div class="mb-3">
<label for="signup_voucher" class="visually-hidden">{{ $t('signup.voucher') }}</label>
<input type="text" class="form-control" id="signup_voucher" :placeholder="$t('signup.voucher')" v-model="voucher">
</div>
<btn v-if="!invitation" class="btn-secondary me-2" @click="stepBack">{{ $t('btn.back') }}</btn>
<btn class="btn-primary" type="submit" icon="check">
<span v-if="invitation">{{ $t('btn.signup') }}</span>
<span v-else>{{ $t('btn.submit') }}</span>
</btn>
</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>
<script>
import PasswordInput from './Widgets/PasswordInput'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-solid-svg-icons/faMobileRetro').definition,
require('@fortawesome/free-solid-svg-icons/faUsers').definition
)
export default {
components: {
PasswordInput
},
data() {
return {
+ checkout: {},
email: '',
first_name: '',
last_name: '',
code: '',
short_code: '',
login: '',
pass: {},
domain: '',
domains: [],
invitation: null,
is_domain: false,
mode: 'email',
plan: null,
plan_icons: {
individual: 'user',
group: 'users',
phone: 'mobile-retro'
},
plans: [],
token: '',
voucher: ''
}
},
computed: {
steps() {
switch (this.mode) {
case 'token':
return 2
case 'mandate':
return 1
case 'email':
default:
return 3
}
}
},
mounted() {
let param = this.$route.params.param;
if (this.$route.name == 'signup-invite') {
axios.get('/api/auth/signup/invitations/' + param, { loader: true })
.then(response => {
this.invitation = response.data
this.login = response.data.login
this.voucher = response.data.voucher
this.first_name = response.data.first_name
this.last_name = response.data.last_name
this.plan = response.data.plan
this.is_domain = response.data.is_domain
this.setDomain(response.data)
this.displayForm(3, true)
})
.catch(error => {
this.$root.errorHandler(error)
})
} else if (param) {
if (this.$route.path.indexOf('/signup/voucher/') === 0) {
// Voucher (discount) code
this.voucher = param
this.displayForm(0)
} else if (/^([A-Z0-9]+)-([a-zA-Z0-9]+)$/.test(param)) {
// Verification code provided, auto-submit Step 2
this.short_code = RegExp.$1
this.code = RegExp.$2
this.submitStep2(true)
} else if (/^([a-zA-Z_]+)$/.test(param)) {
// Plan title provided, save it and display Step 1
this.step0(param)
} else {
this.$root.errorPage(404)
}
} else {
this.displayForm(0)
}
},
methods: {
selectPlan(plan) {
this.$router.push({path: '/signup/' + plan})
this.selectPlanByTitle(plan)
},
// Composes plan selection page
selectPlanByTitle(title) {
const plan = this.plans.filter(plan => plan.title == title)[0]
if (plan) {
this.plan = title
this.mode = plan.mode
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) {
if (!this.plans.length) {
axios.get('/api/auth/signup/plans', { loader: true }).then(response => {
this.plans = response.data.plans
this.selectPlanByTitle(plan)
})
.catch(error => {
this.$root.errorHandler(error)
})
} else {
this.selectPlanByTitle(plan)
}
},
// Submits data to the API, validates and gets verification code
submitStep1() {
this.$root.clearFormValidation($('#step1 form'))
const post = this.$root.pick(this, ['email', 'last_name', 'first_name', 'plan', 'token', 'voucher'])
axios.post('/api/auth/signup/init', post)
.then(response => {
this.code = response.data.code
this.short_code = response.data.short_code
this.mode = response.data.mode
this.is_domain = response.data.is_domain
this.displayForm(this.mode == 'token' ? 3 : 2, true)
// Fill the domain selector with available domains
if (!this.is_domain) {
this.setDomain(response.data)
}
})
},
// Submits the code to the API for verification
submitStep2(bylink) {
if (bylink === true) {
this.displayForm(2, false)
}
this.$root.clearFormValidation($('#step2 form'))
const post = this.$root.pick(this, ['code', 'short_code'])
axios.post('/api/auth/signup/verify', post)
.then(response => {
this.displayForm(3, true)
// Reset user name/email/plan, we don't have them if user used a verification link
this.first_name = response.data.first_name
this.last_name = response.data.last_name
this.email = response.data.email
this.is_domain = response.data.is_domain
this.voucher = response.data.voucher
this.domain = ''
// Fill the domain selector with available domains
if (!this.is_domain) {
this.setDomain(response.data)
}
})
.catch(error => {
if (bylink === true) {
// FIXME: display step 1, user can do nothing about it anyway
// Maybe we should display 404 error page?
this.displayForm(1, true)
}
})
},
// Submits the data to the API to create the user account
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
stepBack(e) {
const card = $(e.target).closest('.card')
let step = card.attr('id').replace('step', '')
card.addClass('d-none').find('form')[0].reset()
step -= 1
if (step == 2 && this.mode == 'token') {
step = 1
}
- if (this.mode == 'mandate') {
+ if (this.mode == 'mandate' && step < 3) {
step = 0
}
$('#step' + step).removeClass('d-none').find('input').first().focus()
if (!step) {
this.step0()
this.$router.replace({path: '/signup'})
}
},
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')
})
if (!step) {
return this.step0()
}
$('#step' + step).removeClass('d-none').find('form')[0].reset()
if (focus) {
$('#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
}
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]
}
}
}
}
}
</script>
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
index c6a5691e..a1f534dc 100644
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -1,449 +1,432 @@
<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>
<div class="card-text">
<p v-if="wallet.notice" id="wallet-notice">{{ wallet.notice }}</p>
<div v-if="showPendingPayments" class="alert alert-warning">
{{ $t('wallet.pending-payments-warning') }}
</div>
<p v-if="$root.hasPermission('walletPayments')">
<btn class="btn-primary" @click="paymentMethodForm('manual')">{{ $t('wallet.add-credit') }}</btn>
</p>
<div id="mandate-form" v-if="!mandate.isValid && !mandate.isPending && $root.hasPermission('walletMandates')">
<template v-if="mandate.id && !mandate.isValid">
<div class="alert alert-danger">
{{ $t('wallet.auto-payment-failed') }}
</div>
<btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn>
</template>
<btn class="btn-primary" @click="paymentMethodForm('auto')">{{ $t('wallet.auto-payment-setup') }}</btn>
</div>
<div id="mandate-info" v-else-if="$root.hasPermission('walletMandates')">
<div v-if="mandate.isDisabled" class="disabled-mandate alert alert-danger">
{{ $t('wallet.auto-payment-disabled') }}
</div>
<template v-else>
<p v-html="$t('wallet.auto-payment-info', { amount: mandate.amount + ' ' + wallet.currency, balance: mandate.balance + ' ' + wallet.currency})"></p>
<p>{{ $t('wallet.payment-method', { method: mandate.method }) }}</p>
</template>
<div v-if="mandate.isPending" class="alert alert-warning">
{{ $t('wallet.auto-payment-inprogress') }}
</div>
<p class="buttons">
<btn class="btn-danger" @click="autoPaymentDelete">{{ $t('wallet.auto-payment-cancel') }}</btn>
<btn class="btn-primary" @click="autoPaymentChange">{{ $t('wallet.auto-payment-change') }}</btn>
</p>
</div>
</div>
</div>
</div>
<tabs class="mt-3" ref="tabs" :tabs="tabs"></tabs>
<div class="tab-content">
<div class="tab-pane active" id="receipts" role="tabpanel" aria-labelledby="tab-receipts">
<div class="card-body">
<div class="card-text">
<p v-if="receipts.length">
{{ $t('wallet.receipts-hint') }}
</p>
<div v-if="receipts.length" class="input-group">
<select id="receipt-id" class="form-control">
<option v-for="(receipt, index) in receipts" :key="index" :value="receipt">{{ receipt }}</option>
</select>
<btn class="btn-secondary" @click="receiptDownload" icon="download">{{ $t('btn.download') }}</btn>
</div>
<p v-if="!receipts.length">
{{ $t('wallet.receipts-none') }}
</p>
</div>
</div>
</div>
<div class="tab-pane" id="history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
<transaction-log v-if="walletId && loadTransactions" class="card-text" :wallet-id="walletId"></transaction-log>
</div>
</div>
<div class="tab-pane" id="payments" role="tabpanel" aria-labelledby="tab-payments">
<div class="card-body">
<payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
</div>
</div>
</div>
<modal-dialog id="payment-dialog" ref="paymentDialog" :title="paymentDialogTitle" @click="payment" :buttons="dialogButtons">
<div id="payment-method" v-if="paymentForm == 'method'">
<form data-validation-prefix="mandate_">
<div id="payment-method-selection">
<a v-for="method in paymentMethods" :key="method.id" @click="selectPaymentMethod(method)" href="#" :class="'card link-' + method.id">
<svg-icon v-if="method.icon" :icon="[method.icon.prefix, method.icon.name]" />
<img v-if="method.image" :src="method.image" />
<span class="name">{{ method.name }}</span>
</a>
</div>
</form>
</div>
<div id="manual-payment" v-if="paymentForm == 'manual'">
<p v-if="wallet.currency != selectedPaymentMethod.currency && selectedPaymentMethod.id != 'bitcoin'">
{{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }}
</p>
<p v-if="selectedPaymentMethod.id == 'bitcoin'">
{{ $t('wallet.coinbase-hint', { wc: wallet.currency }) }}
</p>
<p v-if="selectedPaymentMethod.id == 'banktransfer'">
{{ $t('wallet.banktransfer-hint') }}
</p>
<p>
{{ $t('wallet.payment-amount-hint') }}
</p>
<form id="payment-form" @submit.prevent="payment">
<div class="input-group">
<input type="text" class="form-control" id="amount" v-model="amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
<div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount) && selectedPaymentMethod.exchangeRate" class="alert alert-warning m-0 mt-3">
{{ $t('wallet.payment-warning', { price: $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }) }}
</div>
</form>
<div class="alert alert-warning m-0 mt-3">
{{ $t('wallet.norefund') }}
</div>
</div>
<div id="auto-payment" v-if="paymentForm == 'auto'">
<form data-validation-prefix="mandate_">
<p>
{{ $t('wallet.auto-payment-hint') }}
</p>
<div class="row mb-3">
<label for="mandate_amount" class="col-sm-6 col-form-label">{{ $t('wallet.fill-up') }}</label>
<div class="col-sm-6">
<div class="input-group">
<input type="text" class="form-control" id="mandate_amount" v-model="mandate.amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
</div>
</div>
<div class="row mb-3">
<label for="mandate_balance" class="col-sm-6 col-form-label">{{ $t('wallet.when-below') }}</label>
<div class="col-sm-6">
<div class="input-group">
<input type="text" class="form-control" id="mandate_balance" v-model="mandate.balance" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
</div>
</div>
<p v-if="!mandate.isValid">
{{ $t('wallet.auto-payment-next') }}
</p>
<div v-if="mandate.isValid && mandate.isDisabled" class="disabled-mandate alert alert-danger m-0">
{{ $t('wallet.auto-payment-disabled-next') }}
</div>
</form>
<div class="alert alert-warning m-0 mt-3">
{{ $t('wallet.norefund') }}
</div>
</div>
</modal-dialog>
</div>
</template>
<script>
import ModalDialog from './Widgets/ModalDialog'
import TransactionLog from './Widgets/TransactionLog'
import PaymentLog from './Widgets/PaymentLog'
import { downloadFile } from '../js/utils'
import { library } from '@fortawesome/fontawesome-svg-core'
library.add(
require('@fortawesome/free-brands-svg-icons/faBitcoin').definition,
require('@fortawesome/free-solid-svg-icons/faBuildingColumns').definition,
require('@fortawesome/free-regular-svg-icons/faCreditCard').definition,
require('@fortawesome/free-solid-svg-icons/faDownload').definition,
require('@fortawesome/free-brands-svg-icons/faPaypal').definition
)
export default {
components: {
ModalDialog,
TransactionLog,
PaymentLog
},
data() {
return {
amount: '',
mandate: { amount: 10, balance: 0, method: null },
paymentDialogTitle: null,
paymentForm: null,
nextForm: null,
receipts: [],
stripe: null,
loadTransactions: false,
loadPayments: false,
showPendingPayments: false,
wallet: {},
walletId: null,
paymentMethods: [],
selectedPaymentMethod: null
}
},
computed: {
dialogButtons() {
if (this.paymentForm == 'method') {
return []
}
const button = {
className: 'btn-primary modal-action',
icon: 'check',
label: 'btn.submit'
}
if (this.paymentForm == 'manual'
|| (this.paymentForm == 'auto' && !this.mandate.isValid && !this.mandate.isPending)
) {
button.label = 'btn.continue'
}
return [ button ]
},
tabs() {
let tabs = [ 'wallet.receipts', 'wallet.history' ]
if (this.showPendingPayments) {
tabs.push('wallet.pending-payments')
}
return tabs
}
},
- beforeDestroyed() {
- clearTimeout(this.refreshRequest)
- },
mounted() {
$('#wallet button').focus()
this.walletId = this.$root.authInfo.wallets[0].id
axios.get('/api/v4/wallets/' + this.walletId, { loader: true })
.then(response => {
this.wallet = response.data
axios.get('/api/v4/wallets/' + this.walletId + '/receipts', { loader: '#receipts' })
.then(response => {
this.receipts = response.data.list
})
if (this.wallet.provider == 'stripe') {
this.stripeInit()
}
})
.catch(this.$root.errorHandler)
this.loadMandate()
axios.get('/api/v4/payments/has-pending')
.then(response => {
this.showPendingPayments = response.data.hasPending
})
this.$refs.tabs.clickHandler('history', () => { this.loadTransactions = true })
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
this.selectedPaymentMethod = method
this.paymentForm = this.nextForm
setTimeout(() => { $('#payment-dialog').find('#amount,#mandate_amount').focus() }, 10)
},
payment() {
if (this.paymentForm == 'auto') {
return this.autoPayment()
}
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
this.$root.clearFormValidation($('#payment-form'))
const post = {
amount: this.amount,
methodId: this.selectedPaymentMethod.id,
currency: this.selectedPaymentMethod.currency
}
axios.post('/api/v4/payments', post, { onFinish })
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else if (response.data.newWindowUrl) {
window.open(response.data.newWindowUrl, '_blank')
this.$refs.paymentDialog.hide();
} else {
this.stripeCheckout(response.data)
}
})
},
autoPayment() {
if (this.formLock) {
return
}
// Lock the form to prevent from double submission
this.formLock = true
let onFinish = () => { this.formLock = false }
const method = this.mandate.id && (this.mandate.isValid || this.mandate.isPending) ? 'put' : 'post'
let post = {
amount: this.mandate.amount,
balance: this.mandate.balance,
}
// Modifications can't change the method of payment
if (this.selectedPaymentMethod) {
post.methodId = this.selectedPaymentMethod.id
post.currency = this.selectedPaymentMethod.currency
}
this.$root.clearFormValidation($('#auto-payment form'))
axios[method]('/api/v4/payments/mandate', post, { onFinish })
.then(response => {
if (method == 'post') {
this.mandate.id = null
// a new mandate, redirect to the chackout page
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
} else if (response.data.id) {
this.stripeCheckout(response.data)
}
} else {
// an update
if (response.data.status == 'success') {
this.$refs.paymentDialog.hide();
this.mandate = response.data
this.$toast.success(response.data.message)
}
}
})
},
autoPaymentChange(event) {
this.autoPaymentForm(event, this.$t('wallet.auto-payment-update'))
},
autoPaymentDelete() {
axios.delete('/api/v4/payments/mandate')
.then(response => {
this.mandate = { amount: 10, balance: 0 }
if (response.data.status == 'success') {
this.$toast.success(response.data.message)
}
})
},
paymentMethodForm(nextForm) {
this.formLock = false
this.paymentMethods = []
this.paymentForm = 'method'
this.nextForm = nextForm
this.paymentDialogTitle = this.$t(nextForm == 'auto' ? 'wallet.auto-payment-setup' : 'wallet.top-up')
this.$refs.paymentDialog.show()
this.$nextTick().then(() => {
const type = nextForm == 'manual' ? 'oneoff' : 'recurring'
const loader = ['#payment-method', { 'min-height': '10em', small: false }]
axios.get('/api/v4/payments/methods', { params: { type }, loader })
.then(response => {
this.paymentMethods = response.data
if (this.paymentMethods.length == 1) {
this.nextForm = 'auto';
this.selectPaymentMethod(this.paymentMethods[0]);
}
})
})
},
autoPaymentForm(event, title) {
this.paymentForm = 'auto'
this.paymentDialogTitle = title
this.formLock = false
this.$refs.paymentDialog.show()
},
receiptDownload() {
const receipt = $('#receipt-id').val()
downloadFile('/api/v4/wallets/' + this.walletId + '/receipts/' + receipt)
},
stripeInit() {
let script = $('#stripe-script')
if (!script.length) {
script = document.createElement('script')
script.onload = () => {
this.stripe = Stripe(window.config.stripePK)
}
script.id = 'stripe-script'
script.src = 'https://js.stripe.com/v3/'
document.getElementsByTagName('head')[0].appendChild(script)
} else {
this.stripe = Stripe(window.config.stripePK)
}
},
stripeCheckout(data) {
if (!this.stripe) {
return
}
this.stripe.redirectToCheckout({
sessionId: data.id
}).then(result => {
// If it fails due to a browser or network error,
// display the localized error message to the user
if (result.error) {
this.$toast.error(result.error.message)
}
})
}
}
}
</script>
diff --git a/src/routes/api.php b/src/routes/api.php
index feaad88a..4716f25a 100644
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -1,283 +1,286 @@
<?php
use App\Http\Controllers\API;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
Route::group(
[
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('login', [API\AuthController::class, 'login']);
Route::group(
['middleware' => 'auth:api'],
function () {
Route::get('info', [API\AuthController::class, 'info']);
Route::post('info', [API\AuthController::class, 'info']);
Route::get('location', [API\AuthController::class, 'location']);
Route::post('logout', [API\AuthController::class, 'logout']);
Route::post('refresh', [API\AuthController::class, 'refresh']);
}
);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => 'api',
'prefix' => 'auth'
],
function () {
Route::post('password-policy/check', [API\PasswordPolicyController::class, 'check']);
Route::post('password-reset/init', [API\PasswordResetController::class, 'init']);
Route::post('password-reset/verify', [API\PasswordResetController::class, 'verify']);
Route::post('password-reset', [API\PasswordResetController::class, 'reset']);
Route::get('signup/domains', [API\SignupController::class, 'domains']);
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']);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => ['auth:api', 'scope:mfa,api'],
'prefix' => 'v4'
],
function () {
Route::post('auth-attempts/{id}/confirm', [API\V4\AuthAttemptsController::class, 'confirm']);
Route::post('auth-attempts/{id}/deny', [API\V4\AuthAttemptsController::class, 'deny']);
Route::get('auth-attempts/{id}/details', [API\V4\AuthAttemptsController::class, 'details']);
Route::get('auth-attempts', [API\V4\AuthAttemptsController::class, 'index']);
Route::post('companion/register', [API\V4\CompanionAppsController::class, 'register']);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'middleware' => ['auth:api', 'scope:api'],
'prefix' => 'v4'
],
function () {
Route::apiResource('companions', API\V4\CompanionAppsController::class);
// This must not be accessible with the 2fa token,
// to prevent an attacker from pairing a new device with a stolen token.
Route::get('companions/{id}/pairing', [API\V4\CompanionAppsController::class, 'pairing']);
Route::apiResource('domains', API\V4\DomainsController::class);
Route::get('domains/{id}/confirm', [API\V4\DomainsController::class, 'confirm']);
Route::get('domains/{id}/skus', [API\V4\DomainsController::class, 'skus']);
Route::get('domains/{id}/status', [API\V4\DomainsController::class, 'status']);
Route::post('domains/{id}/config', [API\V4\DomainsController::class, 'setConfig']);
if (\config('app.with_files')) {
Route::apiResource('files', API\V4\FilesController::class);
Route::get('files/{fileId}/permissions', [API\V4\FilesController::class, 'getPermissions']);
Route::post('files/{fileId}/permissions', [API\V4\FilesController::class, 'createPermission']);
Route::put('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'updatePermission']);
Route::delete('files/{fileId}/permissions/{id}', [API\V4\FilesController::class, 'deletePermission']);
Route::post('files/uploads/{id}', [API\V4\FilesController::class, 'upload'])
->withoutMiddleware(['auth:api', 'scope:api'])
->middleware(['api']);
Route::get('files/downloads/{id}', [API\V4\FilesController::class, 'download'])
->withoutMiddleware(['auth:api', 'scope:api']);
}
Route::apiResource('groups', API\V4\GroupsController::class);
Route::get('groups/{id}/skus', [API\V4\GroupsController::class, 'skus']);
Route::get('groups/{id}/status', [API\V4\GroupsController::class, 'status']);
Route::post('groups/{id}/config', [API\V4\GroupsController::class, 'setConfig']);
Route::apiResource('packages', API\V4\PackagesController::class);
Route::apiResource('rooms', API\V4\RoomsController::class);
Route::post('rooms/{id}/config', [API\V4\RoomsController::class, 'setConfig']);
Route::get('rooms/{id}/skus', [API\V4\RoomsController::class, 'skus']);
Route::post('meet/rooms/{id}', [API\V4\MeetController::class, 'joinRoom'])
->withoutMiddleware(['auth:api', 'scope:api']);
Route::apiResource('resources', API\V4\ResourcesController::class);
Route::get('resources/{id}/skus', [API\V4\ResourcesController::class, 'skus']);
Route::get('resources/{id}/status', [API\V4\ResourcesController::class, 'status']);
Route::post('resources/{id}/config', [API\V4\ResourcesController::class, 'setConfig']);
Route::apiResource('shared-folders', API\V4\SharedFoldersController::class);
Route::get('shared-folders/{id}/skus', [API\V4\SharedFoldersController::class, 'skus']);
Route::get('shared-folders/{id}/status', [API\V4\SharedFoldersController::class, 'status']);
Route::post('shared-folders/{id}/config', [API\V4\SharedFoldersController::class, 'setConfig']);
Route::apiResource('skus', API\V4\SkusController::class);
Route::apiResource('users', API\V4\UsersController::class);
Route::post('users/{id}/config', [API\V4\UsersController::class, 'setConfig']);
Route::get('users/{id}/skus', [API\V4\UsersController::class, 'skus']);
Route::get('users/{id}/status', [API\V4\UsersController::class, 'status']);
Route::apiResource('wallets', API\V4\WalletsController::class);
Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']);
Route::get('password-policy', [API\PasswordPolicyController::class, 'index']);
Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']);
Route::delete('password-reset/code/{id}', [API\PasswordResetController::class, 'codeDelete']);
Route::post('payments', [API\V4\PaymentsController::class, 'store']);
//Route::delete('payments', [API\V4\PaymentsController::class, 'cancel']);
Route::get('payments/mandate', [API\V4\PaymentsController::class, 'mandate']);
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'])
->middleware(['api']);
}
);
Route::group(
[
'domain' => \config('app.website_domain'),
'prefix' => 'webhooks'
],
function () {
Route::post('payment/{provider}', [API\V4\PaymentsController::class, 'webhook']);
Route::post('meet', [API\V4\MeetController::class, 'webhook']);
}
);
if (\config('app.with_services')) {
Route::group(
[
'domain' => \config('app.services_domain'),
'prefix' => 'webhooks'
],
function () {
Route::get('nginx', [API\V4\NGINXController::class, 'authenticate']);
Route::get('nginx-roundcube', [API\V4\NGINXController::class, 'authenticateRoundcube']);
Route::get('nginx-httpauth', [API\V4\NGINXController::class, 'httpauth']);
Route::post('cyrus-sasl', [API\V4\NGINXController::class, 'cyrussasl']);
Route::post('policy/greylist', [API\V4\PolicyController::class, 'greylist']);
Route::post('policy/ratelimit', [API\V4\PolicyController::class, 'ratelimit']);
Route::post('policy/spf', [API\V4\PolicyController::class, 'senderPolicyFramework']);
}
);
}
if (\config('app.with_admin')) {
Route::group(
[
'domain' => 'admin.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'admin'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Admin\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Admin\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Admin\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Admin\DomainsController::class, 'unsuspend']);
Route::apiResource('groups', API\V4\Admin\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Admin\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Admin\GroupsController::class, 'unsuspend']);
Route::apiResource('resources', API\V4\Admin\ResourcesController::class);
Route::apiResource('shared-folders', API\V4\Admin\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Admin\SkusController::class);
Route::apiResource('users', API\V4\Admin\UsersController::class);
Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Admin\UsersController::class, 'reset2FA']);
Route::post('users/{id}/resetGeoLock', [API\V4\Admin\UsersController::class, 'resetGeoLock']);
Route::get('users/{id}/skus', [API\V4\Admin\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Admin\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Admin\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Admin\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Admin\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/transactions', [API\V4\Admin\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Admin\StatsController::class, 'chart']);
}
);
}
if (\config('app.with_reseller')) {
Route::group(
[
'domain' => 'reseller.' . \config('app.website_domain'),
'middleware' => ['auth:api', 'reseller'],
'prefix' => 'v4',
],
function () {
Route::apiResource('domains', API\V4\Reseller\DomainsController::class);
Route::get('domains/{id}/skus', [API\V4\Reseller\DomainsController::class, 'skus']);
Route::post('domains/{id}/suspend', [API\V4\Reseller\DomainsController::class, 'suspend']);
Route::post('domains/{id}/unsuspend', [API\V4\Reseller\DomainsController::class, 'unsuspend']);
Route::apiResource('groups', API\V4\Reseller\GroupsController::class);
Route::post('groups/{id}/suspend', [API\V4\Reseller\GroupsController::class, 'suspend']);
Route::post('groups/{id}/unsuspend', [API\V4\Reseller\GroupsController::class, 'unsuspend']);
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', [API\V4\Reseller\InvitationsController::class, 'resend']);
Route::post('payments', [API\V4\Reseller\PaymentsController::class, 'store']);
Route::get('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandate']);
Route::post('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateCreate']);
Route::put('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateUpdate']);
Route::delete('payments/mandate', [API\V4\Reseller\PaymentsController::class, 'mandateDelete']);
Route::get('payments/methods', [API\V4\Reseller\PaymentsController::class, 'paymentMethods']);
Route::get('payments/pending', [API\V4\Reseller\PaymentsController::class, 'payments']);
Route::get('payments/has-pending', [API\V4\Reseller\PaymentsController::class, 'hasPayments']);
Route::apiResource('resources', API\V4\Reseller\ResourcesController::class);
Route::apiResource('shared-folders', API\V4\Reseller\SharedFoldersController::class);
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::get('users/{id}/discounts', [API\V4\Reseller\DiscountsController::class, 'userDiscounts']);
Route::post('users/{id}/reset2FA', [API\V4\Reseller\UsersController::class, 'reset2FA']);
Route::post('users/{id}/resetGeoLock', [API\V4\Reseller\UsersController::class, 'resetGeoLock']);
Route::get('users/{id}/skus', [API\V4\Reseller\UsersController::class, 'skus']);
Route::post('users/{id}/skus/{sku}', [API\V4\Admin\UsersController::class, 'setSku']);
Route::post('users/{id}/suspend', [API\V4\Reseller\UsersController::class, 'suspend']);
Route::post('users/{id}/unsuspend', [API\V4\Reseller\UsersController::class, 'unsuspend']);
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', [API\V4\Reseller\WalletsController::class, 'oneOff']);
Route::get('wallets/{id}/receipts', [API\V4\Reseller\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\Reseller\WalletsController::class, 'receiptDownload']);
Route::get('wallets/{id}/transactions', [API\V4\Reseller\WalletsController::class, 'transactions']);
Route::get('stats/chart/{chart}', [API\V4\Reseller\StatsController::class, 'chart']);
}
);
}
diff --git a/src/tests/Browser/Pages/Signup.php b/src/tests/Browser/Pages/PaymentStatus.php
similarity index 58%
copy from src/tests/Browser/Pages/Signup.php
copy to src/tests/Browser/Pages/PaymentStatus.php
index 474388f4..7843964d 100644
--- a/src/tests/Browser/Pages/Signup.php
+++ b/src/tests/Browser/Pages/PaymentStatus.php
@@ -1,51 +1,46 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
-class Signup extends Page
+class PaymentStatus extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
- return '/signup';
+ return '/payment/status';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
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');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
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
index 474388f4..0608d35b 100644
--- a/src/tests/Browser/Pages/Signup.php
+++ b/src/tests/Browser/Pages/Signup.php
@@ -1,51 +1,52 @@
<?php
namespace Tests\Browser\Pages;
use Laravel\Dusk\Page;
class Signup extends Page
{
/**
* Get the URL for the page.
*
* @return string
*/
public function url(): string
{
return '/signup';
}
/**
* Assert that the browser is on the page.
*
* @param \Laravel\Dusk\Browser $browser The browser object
*
* @return void
*/
public function assert($browser)
{
$browser->assertPathIs('/signup')
->waitUntilMissing('.app-loader')
->assertPresent('@step0')
->assertPresent('@step1')
->assertPresent('@step2')
->assertPresent('@step3');
}
/**
* Get the element shortcuts for the page.
*
* @return array
*/
public function elements(): array
{
return [
'@app' => '#app',
'@step0' => '#step0',
'@step1' => '#step1',
'@step2' => '#step2',
'@step3' => '#step3',
+ '@step4' => '#step4',
];
}
}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
index eaef5390..2b301f6f 100644
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -1,828 +1,861 @@
<?php
namespace Tests\Browser;
use App\Discount;
use App\Domain;
use App\Plan;
use App\SignupCode;
use App\SignupInvitation;
use App\User;
use Tests\Browser;
use Tests\Browser\Components\Menu;
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;
class SignupTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('signuptestdusk@' . \config('app.domain'));
$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]);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('signuptestdusk@' . \config('app.domain'));
$this->deleteTestUser('admin@user-domain-signup.com');
$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'));
parent::tearDown();
}
/**
* Test signup code verification with a link
*/
public function testSignupCodeByLink(): void
{
// Test invalid code (invalid format)
$this->browse(function (Browser $browser) {
// Register Signup page element selectors we'll be using
$browser->onWithoutAssert(new Signup());
// TODO: Test what happens if user is logged in
$browser->visit('/signup/invalid-code');
// TODO: According to https://github.com/vuejs/vue-router/issues/977
// it is not yet easily possible to display error page component (route)
// without changing the URL
// TODO: Instead of css selector we should probably define page/component
// and use it instead
$browser->waitFor('#error-page');
});
// Test invalid code (valid format)
$this->browse(function (Browser $browser) {
$browser->visit('/signup/XXXXX-code');
// FIXME: User will not be able to continue anyway, so we should
// either display 1st step or 404 error page
$browser->waitFor('@step1')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Test valid code
$this->browse(function (Browser $browser) {
$code = SignupCode::create([
'email' => 'User@example.org',
'first_name' => 'User',
'last_name' => 'Name',
'plan' => 'individual',
'voucher' => '',
]);
$browser->visit('/signup/' . $code->short_code . '-' . $code->code)
->waitFor('@step3')
->assertMissing('@step1')
->assertMissing('@step2');
// FIXME: Find a nice way to read javascript data without using hidden inputs
$this->assertSame($code->code, $browser->value('@step2 #signup_code'));
// TODO: Test if the signup process can be completed
});
}
/**
* Test signup "welcome" page
*/
public function testSignupStep0(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Signup());
$browser->assertVisible('@step0')
->assertMissing('@step1')
->assertMissing('@step2')
->assertMissing('@step3');
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang'], 'signup');
});
$browser->waitFor('@step0 .plan-selector .card');
// Assert first plan box and press the button
$browser->with('@step0 .plan-selector .plan-individual', function ($step) {
$step->assertVisible('button')
->assertSeeIn('button', 'Individual Account')
->assertVisible('.plan-description')
->click('button');
});
$browser->waitForLocation('/signup/individual')
->assertVisible('@step1')
->assertSeeIn('.card-title', 'Sign Up - Step 1/3')
->assertMissing('@step0')
->assertMissing('@step2')
->assertMissing('@step3')
->assertFocused('@step1 #signup_first_name');
// Click Back button
$browser->click('@step1 [type=button]')
->waitForLocation('/signup')
->assertVisible('@step0')
->assertMissing('@step1')
->assertMissing('@step2')
->assertMissing('@step3');
// Choose the group account plan
$browser->click('@step0 .plan-selector .plan-group button')
->waitForLocation('/signup/group')
->assertVisible('@step1')
->assertMissing('@step0')
->assertMissing('@step2')
->assertMissing('@step3')
->assertFocused('@step1 #signup_first_name');
// TODO: Test if 'plan' variable is set properly in vue component
});
}
/**
* Test 1st step of the signup process
*/
public function testSignupStep1(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/signup/individual')
->onWithoutAssert(new Signup());
// Here we expect two text inputs and Back and Continue buttons
$browser->with('@step1', function ($step) {
$step->waitFor('#signup_last_name')
->assertSeeIn('.card-title', 'Sign Up - Step 1/3')
->assertVisible('#signup_first_name')
->assertFocused('#signup_first_name')
->assertVisible('#signup_email')
->assertVisible('[type=button]')
->assertVisible('[type=submit]');
});
// Submit empty form
// Email is required, so after pressing Submit
// we expect focus to be moved to the email input
$browser->with('@step1', function ($step) {
$step->click('[type=submit]');
$step->assertFocused('#signup_email');
});
$browser->within(new Menu(), function ($browser) {
$browser->assertMenuItems(['signup', 'explore', 'blog', 'support', 'login', 'lang'], 'signup');
});
// Submit invalid email, and first_name
// We expect both inputs to have is-invalid class added, with .invalid-feedback element
$browser->with('@step1', function ($step) {
$step->type('#signup_first_name', str_repeat('a', 250))
->type('#signup_email', '@test')
->click('[type=submit]')
->waitFor('#signup_email.is-invalid')
->assertVisible('#signup_first_name.is-invalid')
->assertVisible('#signup_email + .invalid-feedback')
->assertVisible('#signup_last_name + .invalid-feedback')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit valid data
// We expect error state on email input to be removed, and Step 2 form visible
$browser->with('@step1', function ($step) {
$step->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]')
->assertMissing('#signup_email.is-invalid')
->assertMissing('#signup_email + .invalid-feedback');
});
$browser->waitUntilMissing('@step2 #signup_code[value=""]');
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
});
}
/**
* Test 2nd Step of the signup process
*
* @depends testSignupStep1
*/
public function testSignupStep2(): void
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step2')
->assertSeeIn('@step2 .card-title', 'Sign Up - Step 2/3')
->assertMissing('@step0')
->assertMissing('@step1')
->assertMissing('@step3');
// Here we expect one text input, Back and Continue buttons
$browser->with('@step2', function ($step) {
$step->assertVisible('#signup_short_code')
->assertFocused('#signup_short_code')
->assertVisible('[type=button]')
->assertVisible('[type=submit]');
});
// Test Back button functionality
$browser->click('@step2 [type=button]')
->waitFor('@step1')
->assertFocused('@step1 #signup_first_name')
->assertMissing('@step2');
// Submit valid Step 1 data (again)
$browser->with('@step1', function ($step) {
$step->type('#signup_first_name', 'User')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]');
});
$browser->waitFor('@step2');
$browser->assertMissing('@step1');
// Submit invalid code
// We expect code input to have is-invalid class added, with .invalid-feedback element
$browser->with('@step2', function ($step) {
$step->type('#signup_short_code', 'XXXXX');
$step->click('[type=submit]');
$step->waitFor('#signup_short_code.is-invalid')
->assertVisible('#signup_short_code + .invalid-feedback')
->assertFocused('#signup_short_code')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit valid code
// We expect error state on code input to be removed, and Step 3 form visible
$browser->with('@step2', function ($step) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $step->value('#signup_code');
$this->assertNotEmpty($code);
$code = SignupCode::find($code);
$step->type('#signup_short_code', $code->short_code);
$step->click('[type=submit]');
$step->assertMissing('#signup_short_code.is-invalid');
$step->assertMissing('#signup_short_code + .invalid-feedback');
});
$browser->waitFor('@step3');
$browser->assertMissing('@step2');
});
}
/**
* Test 3rd Step of the signup process
*
* @depends testSignupStep2
*/
public function testSignupStep3(): void
{
$this->browse(function (Browser $browser) {
$browser->assertVisible('@step3');
// Here we expect 3 text inputs, Back and Continue buttons
$browser->with('@step3', function ($step) {
$domains = Domain::getPublicDomains();
$domains_count = count($domains);
$step->assertSeeIn('.card-title', 'Sign Up - Step 3/3')
->assertMissing('#signup_last_name')
->assertMissing('#signup_first_name')
->assertVisible('#signup_login')
->assertVisible('#signup_password')
->assertVisible('#signup_password_confirmation')
->assertVisible('select#signup_domain')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertText('select#signup_domain option:nth-child(1)', $domains[0])
->assertValue('select#signup_domain option:nth-child(1)', $domains[0])
->assertText('select#signup_domain option:nth-child(2)', $domains[1])
->assertValue('select#signup_domain option:nth-child(2)', $domains[1])
->assertVisible('[type=button]')
->assertVisible('[type=submit]')
->assertSeeIn('[type=submit]', 'Submit')
->assertFocused('#signup_login')
->assertSelected('select#signup_domain', \config('app.domain'))
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
->assertValue('#signup_password_confirmation', '')
->with('#signup_password_policy', function (Browser $browser) {
$browser->assertElementsCount('li', 2)
->assertMissing('li:first-child svg.text-success')
->assertSeeIn('li:first-child small', "Minimum password length: 6 characters")
->assertMissing('li:last-child svg.text-success')
->assertSeeIn('li:last-child small', "Maximum password length: 255 characters");
});
// TODO: Test domain selector
});
// Test Back button
$browser->click('@step3 [type=button]');
$browser->waitFor('@step2');
$browser->assertFocused('@step2 #signup_short_code');
$browser->assertMissing('@step3');
// TODO: Test form reset when going back
// Submit valid code again
$browser->with('@step2', function ($step) {
$code = $step->value('#signup_code');
$this->assertNotEmpty($code);
$code = SignupCode::find($code);
$step->type('#signup_short_code', $code->short_code);
$step->click('[type=submit]');
});
$browser->waitFor('@step3');
// Submit invalid data
$browser->with('@step3', function ($step) {
$step->assertFocused('#signup_login')
->type('#signup_login', '*')
->type('#signup_password', '12345678')
->type('#signup_password_confirmation', '123456789')
->with('#signup_password_policy', function (Browser $browser) {
$browser->waitFor('li:first-child svg.text-success')
->waitFor('li:last-child svg.text-success');
})
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
->assertVisible('#signup_password_input .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit invalid data (valid login, invalid password)
$browser->with('@step3', function ($step) {
$step->type('#signup_login', 'SignupTestDusk')
->click('[type=submit]')
->waitFor('#signup_password.is-invalid')
->assertVisible('#signup_password_input .invalid-feedback')
->assertMissing('#signup_login.is-invalid')
->assertMissing('#signup_domain + .invalid-feedback')
->assertFocused('#signup_password')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit valid data
$browser->with('@step3', function ($step) {
$step->type('#signup_password_confirmation', '12345678');
$step->click('[type=submit]');
});
// At this point we should be auto-logged-in to dashboard
$browser->waitUntilMissing('@step3')
->waitUntilMissing('.app-loader')
->on(new Dashboard())
->assertUser('signuptestdusk@' . \config('app.domain'))
->assertVisible('@links a.link-profile')
->assertMissing('@links a.link-domains')
->assertVisible('@links a.link-users')
->assertVisible('@links a.link-wallet');
// Logout the user
$browser->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
}
/**
* Test signup for a group account
*/
public function testSignupGroup(): void
{
$this->browse(function (Browser $browser) {
$browser->visit(new Signup());
// Choose the group account plan
$browser->waitFor('@step0 .plan-group button')
->click('@step0 .plan-group button');
// Submit valid data
// We expect error state on email input to be removed, and Step 2 form visible
$browser->whenAvailable('@step1', function ($step) {
$step->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]');
});
// Submit valid code
$browser->whenAvailable('@step2', function ($step) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $step->value('#signup_code');
$code = SignupCode::find($code);
$step->type('#signup_short_code', $code->short_code)
->click('[type=submit]');
});
// Here we expect 4 text inputs, Back and Continue buttons
$browser->whenAvailable('@step3', function ($step) {
$step->assertVisible('#signup_login')
->assertVisible('#signup_password')
->assertVisible('#signup_password_confirmation')
->assertVisible('input#signup_domain')
->assertVisible('[type=button]')
->assertVisible('[type=submit]')
->assertFocused('#signup_login')
->assertValue('input#signup_domain', '')
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
->assertValue('#signup_password_confirmation', '');
});
// Submit invalid login and password data
$browser->with('@step3', function ($step) {
$step->assertFocused('#signup_login')
->type('#signup_login', '*')
->type('#signup_domain', 'test.com')
->type('#signup_password', '12345678')
->type('#signup_password_confirmation', '123456789')
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
->assertVisible('#signup_password_input .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit invalid domain
$browser->with('@step3', function ($step) {
$step->type('#signup_login', 'admin')
->type('#signup_domain', 'aaa')
->type('#signup_password', '12345678')
->type('#signup_password_confirmation', '12345678')
->click('[type=submit]')
->waitUntilMissing('#signup_login.is-invalid')
->waitFor('#signup_domain.is-invalid + .invalid-feedback')
->assertMissing('#signup_password.is-invalid')
->assertMissing('#signup_password_input .invalid-feedback')
->assertFocused('#signup_domain')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
});
// Submit invalid domain
$browser->with('@step3', function ($step) {
$step->type('#signup_domain', 'user-domain-signup.com')
->click('[type=submit]');
});
// At this point we should be auto-logged-in to dashboard
$browser->waitUntilMissing('@step3')
->waitUntilMissing('.app-loader')
->on(new Dashboard())
->assertUser('admin@user-domain-signup.com')
->assertVisible('@links a.link-profile')
->assertVisible('@links a.link-domains')
->assertVisible('@links a.link-users')
->assertVisible('@links a.link-wallet');
$browser->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
}
/**
- * 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
->whenAvailable('@step3', function ($browser) {
$browser->click('button[type=button]');
})
->whenAvailable('@step0', function ($browser) {
$browser->click('.plan-individual button');
})
// Test submit
->whenAvailable('@step3', function ($browser) {
$domains = Domain::getPublicDomains();
$domains_count = count($domains);
$browser->assertMissing('.card-title')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertText('select#signup_domain option:nth-child(1)', $domains[0])
->assertValue('select#signup_domain option:nth-child(1)', $domains[0])
->type('#signup_login', 'signuptestdusk')
->type('#signup_password', '12345678')
->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
}
/**
* Test signup with a token plan
*/
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'];
file_put_contents(storage_path('signup-tokens.txt'), implode("\n", $tokens));
$this->browse(function (Browser $browser) use ($tokens) {
$browser->visit(new Signup())
->waitFor('@step0 .plan-individual button')
->click('@step0 .plan-individual button')
// Step 1
->whenAvailable('@step1', function ($browser) use ($tokens) {
$browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2')
->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->assertMissing('#signup_email')
->type('#signup_token', '1234')
// invalid token
->click('[type=submit]')
->waitFor('#signup_token.is-invalid')
->assertVisible('#signup_token + .invalid-feedback')
->assertFocused('#signup_token')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// valid token
->type('#signup_token', $tokens[0])
->click('[type=submit]');
})
// Step 2
->whenAvailable('@step3', function ($browser) {
$domains = Domain::getPublicDomains();
$domains_count = count($domains);
$browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertText('select#signup_domain option:nth-child(1)', $domains[0])
->assertValue('select#signup_domain option:nth-child(1)', $domains[0])
->type('#signup_login', 'signuptestdusk')
->type('#signup_password', '12345678')
->type('#signup_password_confirmation', '12345678')
->click('[type=submit]');
})
->waitUntilMissing('@step3')
->on(new Dashboard())
->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
$user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first();
$this->assertSame($tokens[0], $user->getSetting('signup_token'));
$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())
->waitFor('@step0 .plan-group button')
->click('@step0 .plan-group button')
// Step 1
->whenAvailable('@step1', function ($browser) use ($tokens) {
$browser->assertSeeIn('.card-title', 'Sign Up - Step 1/2')
->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->assertMissing('#signup_email')
->type('#signup_token', '1234')
// invalid token
->click('[type=submit]')
->waitFor('#signup_token.is-invalid')
->assertVisible('#signup_token + .invalid-feedback')
->assertFocused('#signup_token')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// valid token
->type('#signup_token', $tokens[1])
->click('[type=submit]');
})
// Step 2
->whenAvailable('@step3', function ($browser) {
$browser->assertSeeIn('.card-title', 'Sign Up - Step 2/2')
->type('input#signup_domain', 'user-domain-signup.com')
->type('#signup_login', 'admin')
->type('#signup_password', '12345678')
->type('#signup_password_confirmation', '12345678')
->click('[type=submit]');
})
->waitUntilMissing('@step3')
->on(new Dashboard())
->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
$user = User::where('email', 'admin@user-domain-signup.com')->first();
$this->assertSame($tokens[1], $user->getSetting('signup_token'));
$this->assertSame(null, $user->getSetting('external_email'));
}
/**
* Test signup with voucher
*/
public function testSignupVoucherLink(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/signup/voucher/TEST')
->onWithoutAssert(new Signup())
->waitUntilMissing('.app-loader')
->waitFor('@step0')
->click('.plan-individual button')
->whenAvailable('@step1', function (Browser $browser) {
$browser->type('#signup_first_name', 'Test')
->type('#signup_last_name', 'User')
->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
->click('[type=submit]');
})
->whenAvailable('@step2', function (Browser $browser) {
// Get the code and short_code from database
// FIXME: Find a nice way to read javascript data without using hidden inputs
$code = $browser->value('#signup_code');
$this->assertNotEmpty($code);
$code = SignupCode::find($code);
$browser->type('#signup_short_code', $code->short_code)
->click('[type=submit]');
})
->whenAvailable('@step3', function (Browser $browser) {
// Assert that the code is filled in the input
// Change it and test error handling
$browser->assertValue('#signup_voucher', 'TEST')
->type('#signup_voucher', 'TESTXX')
->type('#signup_login', 'signuptestdusk')
->type('#signup_password', '123456789')
->type('#signup_password_confirmation', '123456789')
->click('[type=submit]')
->waitFor('#signup_voucher.is-invalid')
->assertVisible('#signup_voucher + .invalid-feedback')
->assertFocused('#signup_voucher')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
// Submit the correct code
->type('#signup_voucher', 'TEST')
->click('[type=submit]');
})
->waitUntilMissing('@step3')
->waitUntilMissing('.app-loader')
->on(new Dashboard())
->assertUser('signuptestdusk@' . \config('app.domain'))
// Logout the user
->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
$user = $this->getTestUser('signuptestdusk@' . \config('app.domain'));
$discount = Discount::where('code', 'TEST')->first();
$this->assertSame($discount->id, $user->wallets()->first()->discount_id);
}
/**
* Test signup via invitation link
*/
public function testSignupInvitation(): void
{
// Test non-existing invitation
$this->browse(function (Browser $browser) {
$browser->visit('/signup/invite/TEST')
->onWithoutAssert(new Signup())
->waitFor('#app > #error-page')
->assertErrorPage(404);
});
$invitation = SignupInvitation::create(['email' => 'test@domain.org']);
$this->browse(function (Browser $browser) use ($invitation) {
$browser->visit('/signup/invite/' . $invitation->id)
->onWithoutAssert(new Signup())
->waitUntilMissing('.app-loader')
->with('@step3', function ($step) {
$domains_count = count(Domain::getPublicDomains());
$step->assertMissing('.card-title')
->assertVisible('#signup_last_name')
->assertVisible('#signup_first_name')
->assertVisible('#signup_login')
->assertVisible('#signup_password')
->assertVisible('#signup_password_confirmation')
->assertVisible('select#signup_domain')
->assertElementsCount('select#signup_domain option', $domains_count, false)
->assertVisible('[type=submit]')
->assertMissing('[type=button]') // Back button
->assertSeeIn('[type=submit]', 'Sign Up')
->assertFocused('#signup_first_name')
->assertValue('select#signup_domain', \config('app.domain'))
->assertValue('#signup_first_name', '')
->assertValue('#signup_last_name', '')
->assertValue('#signup_login', '')
->assertValue('#signup_password', '')
->assertValue('#signup_password_confirmation', '');
// Submit invalid data
$step->type('#signup_login', '*')
->type('#signup_password', '12345678')
->type('#signup_password_confirmation', '123456789')
->click('[type=submit]')
->waitFor('#signup_login.is-invalid')
->assertVisible('#signup_domain + .invalid-feedback')
->assertVisible('#signup_password.is-invalid')
->assertVisible('#signup_password_input .invalid-feedback')
->assertFocused('#signup_login')
->assertToast(Toast::TYPE_ERROR, 'Form validation error');
// Submit valid data
$step->type('#signup_password_confirmation', '12345678')
->type('#signup_login', 'signuptestdusk')
->type('#signup_first_name', 'First')
->type('#signup_last_name', 'Last')
->click('[type=submit]');
})
// At this point we should be auto-logged-in to dashboard
->waitUntilMissing('@step3')
->waitUntilMissing('.app-loader')
->on(new Dashboard())
->assertUser('signuptestdusk@' . \config('app.domain'))
// Logout the user
->within(new Menu(), function ($browser) {
$browser->clickMenuItem('logout');
});
});
$invitation->refresh();
$user = User::where('email', 'signuptestdusk@' . \config('app.domain'))->first();
$this->assertTrue($invitation->isCompleted());
$this->assertSame($user->id, $invitation->user_id);
$this->assertSame('First', $user->getSetting('first_name'));
$this->assertSame('Last', $user->getSetting('last_name'));
$this->assertSame($invitation->email, $user->getSetting('external_email'));
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
index 6aa7d175..a29d8f9b 100644
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -1,1193 +1,1193 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Plan;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use App\VatRate;
use App\Utils;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
use Tests\MollieMocksTrait;
class PaymentsMollieTest extends TestCase
{
use MollieMocksTrait;
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
\config(['app.vat.mode' => 0]);
Utils::setTestExchangeRates(['EUR' => '0.90503424978382']);
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::query()->delete();
VatRate::query()->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email', 'months' => 1]);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::query()->delete();
VatRate::query()->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['mode' => 'email', 'months' => 1]);
Utils::setTestExchangeRates([]);
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
*/
public function testMandates(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Test creating a mandate (invalid input)
$post = [];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => 100, 'balance' => 'a'];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The balance must be a number.', $json['errors']['balance'][0]);
// Test creating a mandate (amount smaller than the minimum value)
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
$post = ['amount' => Payment::MIN_AMOUNT / 100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
// Assert the proper payment amount has been used
$payment = Payment::where('id', $json['id'])->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($wallet->id, $payment->wallet_id);
$this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description);
$this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Credit Card', $json['method']);
$this->assertSame(true, $json['isPending']);
$this->assertSame(false, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$mandate_id = $json['id'];
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$mollie_response = [
'resource' => 'mandate',
'id' => $mandate_id,
'status' => 'valid',
'method' => 'creditcard',
'details' => [
'cardNumber' => '4242',
'cardLabel' => 'Visa',
],
'customerId' => 'cst_GMfxGPt7Gj',
'createdAt' => '2020-04-28T11:09:47+00:00',
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
Bus::fake();
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
// Test updating mandate details (invalid input)
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
$wallet->refresh();
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
// Test updating a disabled mandate (invalid input)
$wallet->setSetting('mandate_disabled', 1);
$wallet->balance = -2000;
$wallet->save();
$user->refresh(); // required so the controller sees the wallet update from above
$post = ['amount' => 15.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
// Test updating a disabled mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
$this->unmockMollie();
// Delete mandate
$response = $this->actingAs($user)->delete("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been removed.', $json['message']);
// Confirm with Mollie the mandate does not exist
$customer_id = $wallet->getSetting('mollie_id');
$this->expectException(\Mollie\Api\Exceptions\ApiException::class);
$this->expectExceptionMessageMatches('/410: Gone/');
$mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
// Test Mollie's "410 Gone" response handling when fetching the mandate info
// It is expected to remove the mandate reference
$mollie_response = [
'status' => 410,
'title' => "Gone",
'detail' => "You are trying to access an object, which has previously been deleted",
'_links' => [
'documentation' => [
'href' => "https://docs.mollie.com/errors",
'type' => "text/html"
]
]
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(410, [], json_encode($mollie_response)));
$wallet->fresh()->setSetting('mollie_mandate_id', '123');
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse(array_key_exists('id', $json));
$this->assertFalse(array_key_exists('method', $json));
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
}
/**
* Test fetching an outo-payment mandate parameters
*
* @group mollie
*/
public function testMandateParams(): void
{
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$user = $this->getTestUser('payment-test@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame((int) ceil(Payment::MIN_AMOUNT / 100), $json['amount']);
$this->assertSame($json['amount'], $json['minAmount']);
$this->assertSame(0, $json['balance']);
$this->assertFalse($json['isValid']);
$this->assertFalse($json['isDisabled']);
$plan->months = 12;
$plan->save();
$user->setSetting('plan_id', $plan->id);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$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
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group mollie
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Invalid amount
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Invalid currency
$post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(500);
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(1234, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($user->tenant->title . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
// Note: Webhook end-point does not require authentication
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
$mollie_response['paidAt'] = date('c');
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
$payment->status = Payment::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame('failed', $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test creating a payment and receiving a status via webhook using a foreign currency
*
* @group mollie
*/
public function testStoreAndWebhookForeignCurrency(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Successful payment in EUR
$post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'banktransfer'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$payment = $wallet->payments()
->where('currency', 'EUR')->get()->last();
$this->assertSame(1234, $payment->amount);
$this->assertSame(1117, $payment->currency_amount);
$this->assertSame('EUR', $payment->currency);
$this->assertEquals(0, $wallet->balance);
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
}
/**
* Test automatic payment charges
*
* @group mollie
*/
public function testTopUp(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Create a valid mandate first (balance=0, so there's no extra payment yet)
$this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
$wallet->setSetting('mandate_balance', 10);
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$this->assertTrue(PaymentsController::topUpWallet($wallet));
// Check that the payments table contains a new record with proper amount.
// There should be two records, one for the mandate payment and another for
// the top-up payment
$payments = $wallet->payments()->orderBy('amount')->get();
$this->assertCount(2, $payments);
$this->assertSame(0, $payments[0]->amount);
$this->assertSame(0, $payments[0]->currency_amount);
$this->assertSame(2010, $payments[1]->amount);
$this->assertSame(2010, $payments[1]->currency_amount);
$payment = $payments[1];
// In mollie we don't have to wait for a webhook, the response to
// PaymentIntent already sets the status to 'paid', so we can test
// immediately the balance update
// Assert that email notification job has been dispatched
$this->assertSame(Payment::STATUS_PAID, $payment->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)",
$transaction->description
);
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if balance is ok
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if the top-up amount is not enough
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = -2050;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// Expect no payment if there's no mandate
$wallet->setSetting('mollie_mandate_id', null);
$wallet->balance = 0;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
// Test webhook for recurring payments
$wallet->transactions()->delete();
$responseStack = $this->mockMollie();
Bus::fake();
$payment->refresh();
$payment->status = Payment::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure
$payment->refresh();
$payment->status = Payment::STATUS_OPEN;
$payment->save();
$wallet->setSetting('mollie_mandate_id', 'xxx');
$wallet->setSetting('mandate_disabled', null);
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertSame(Payment::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->balance);
$this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
$this->unmockMollie();
}
/**
* Test payment/top-up with VAT_MODE=1
*
* @group mollie
*/
public function testPaymentsWithVatModeOne(): void
{
\config(['app.vat.mode' => 1]);
$user = $this->getTestUser('payment-test@' . \config('app.domain'));
$user->setSetting('country', 'US');
$wallet = $user->wallets()->first();
$vatRate = VatRate::create([
'country' => 'US',
'rate' => 5.0,
'start' => now()->subDay(),
]);
// Payment
$post = ['amount' => '10', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(1000 + intval(round(1000 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(1000, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$this->assertSame('open', $payment->status);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (mandate creation)
// Create a valid mandate first (expect an extra payment)
$this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (recurring payment)
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$this->assertTrue(PaymentsController::topUpWallet($wallet));
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
}
/**
* Test refund/chargeback handling by the webhook
*
* @group mollie
*/
public function testRefundAndChargeback(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
$mollie = PaymentProvider::factory('mollie');
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
'status' => Payment::STATUS_PAID,
'amount' => 123,
'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'CHF',
'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'description' => 'test',
]);
// Test handling a refund by the webhook
$mollie_response1 = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
"_links" => [
"refunds" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
"type" => "application/hal+json"
]
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"refunds" => [
[
"resource" => "refund",
"id" => "re_123456",
"status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
"paymentId" => $payment->id,
"description" => "refund desc",
"amount" => [
"currency" => "CHF",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-101, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-101, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type);
$this->assertSame("refund desc", $transactions[0]->description);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-101, $payments[0]->amount);
$this->assertSame(-101, $payments[0]->currency_amount);
$this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
$this->assertSame(Payment::TYPE_REFUND, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame("refund desc", $payments[0]->description);
// Test handling a chargeback by the webhook
$mollie_response1["_links"] = [
"chargebacks" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks",
"type" => "application/hal+json"
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"chargebacks" => [
[
"resource" => "chargeback",
"id" => "chb_123456",
"paymentId" => $payment->id,
"amount" => [
"currency" => "CHF",
"value" => "0.15",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-116, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-15, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type);
$this->assertSame('', $transactions[0]->description);
$payments = $wallet->payments()->where('id', 'chb_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-15, $payments[0]->amount);
$this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
$this->assertSame(Payment::TYPE_CHARGEBACK, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame('', $payments[0]->description);
Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class);
$this->unmockMollie();
}
/**
* Test refund/chargeback handling by the webhook in a foreign currency
*
* @group mollie
*/
public function testRefundAndChargebackForeignCurrency(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
$mollie = PaymentProvider::factory('mollie');
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
'status' => Payment::STATUS_PAID,
'amount' => 1234,
'credit_amount' => 1234,
'currency_amount' => 1117,
'currency' => 'EUR',
'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'description' => 'test',
]);
// Test handling a refund by the webhook
$mollie_response1 = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
"_links" => [
"refunds" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
"type" => "application/hal+json"
]
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"refunds" => [
[
"resource" => "refund",
"id" => "re_123456",
"status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
"paymentId" => $payment->id,
"description" => "refund desc",
"amount" => [
"currency" => "EUR",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertTrue($wallet->balance <= -100);
$this->assertTrue($wallet->balance >= -114);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertTrue($payments[0]->amount <= -100);
$this->assertTrue($payments[0]->amount >= -114);
$this->assertSame(-101, $payments[0]->currency_amount);
$this->assertSame('EUR', $payments[0]->currency);
$this->unmockMollie();
}
/**
* Create Mollie's auto-payment mandate using our API and Chrome browser
*/
protected function createMandate(Wallet $wallet, array $params)
{
// Use the API to create a first payment with a mandate
$response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params);
$response->assertStatus(200);
$json = $response->json();
// There's no easy way to confirm a created mandate.
// The only way seems to be to fire up Chrome on checkout page
// and do actions with use of Dusk browser.
$this->startBrowser()->visit($json['redirectUrl']);
$molliePage = new \Tests\Browser\Pages\PaymentMollie();
$molliePage->assert($this->browser);
$molliePage->submitPayment($this->browser, 'paid');
$this->stopBrowser();
}
/**
* Test listing a pending payment
*
* @group mollie
*/
public function testListingPayments(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
//Empty response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
$wallet = $user->wallets()->first();
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
//A response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(1, $json['list']);
$this->assertSame(Payment::STATUS_OPEN, $json['list'][0]['status']);
$this->assertSame('CHF', $json['list'][0]['currency']);
$this->assertSame(Payment::TYPE_ONEOFF, $json['list'][0]['type']);
$this->assertSame(1234, $json['list'][0]['amount']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(true, $json['hasPending']);
// Set the payment to paid
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$payment->status = Payment::STATUS_PAID;
$payment->save();
// They payment should be gone from the pending list now
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
}
/**
* Test listing payment methods
*
* @group mollie
*/
public function testListingPaymentMethods(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(3 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('banktransfer', $json[2]['id']);
$this->assertSame('CHF', $json[0]['currency']);
$this->assertSame('CHF', $json[1]['currency']);
$this->assertSame('EUR', $json[2]['currency']);
if ($hasCoinbase) {
$this->assertSame('bitcoin', $json[3]['id']);
$this->assertSame('BTC', $json[3]['currency']);
}
$response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('CHF', $json[0]['currency']);
}
}
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
index 05dc0f20..362e2fc4 100644
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -1,1004 +1,1004 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\SignupController;
use App\Discount;
use App\Domain;
use App\Plan;
use App\Package;
use App\SignupCode;
use App\SignupInvitation as SI;
use App\User;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class SignupTest extends TestCase
{
private $domain;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// TODO: Some tests depend on existence of individual and group plans,
// we should probably create plans here to not depend on that
$this->domain = $this->getPublicDomain();
$this->deleteTestUser("SignupControllerTest1@$this->domain");
$this->deleteTestUser("signuplogin@$this->domain");
$this->deleteTestUser("admin@external.com");
$this->deleteTestUser("test-inv@kolabnow.com");
$this->deleteTestDomain('external.com');
$this->deleteTestDomain('signup-domain.com');
$this->deleteTestGroup('group-test@kolabnow.com');
SI::truncate();
Plan::where('title', 'test')->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser("SignupControllerTest1@$this->domain");
$this->deleteTestUser("signuplogin@$this->domain");
$this->deleteTestUser("admin@external.com");
$this->deleteTestUser("test-inv@kolabnow.com");
$this->deleteTestDomain('external.com');
$this->deleteTestDomain('signup-domain.com');
$this->deleteTestGroup('group-test@kolabnow.com');
SI::truncate();
Plan::where('title', 'test')->delete();
parent::tearDown();
}
/**
* Return a public domain for signup tests
*/
private function getPublicDomain(): string
{
if (!$this->domain) {
$this->refreshApplication();
$public_domains = Domain::getPublicDomains();
$this->domain = reset($public_domains);
if (empty($this->domain)) {
$this->domain = 'signup-domain.com';
Domain::create([
'namespace' => $this->domain,
'status' => Domain::STATUS_ACTIVE,
'type' => Domain::TYPE_PUBLIC,
]);
}
}
return $this->domain;
}
/**
* Test fetching public domains for signup
*/
public function testSignupDomains(): void
{
$response = $this->get('/api/auth/signup/domains');
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(Domain::getPublicDomains(), $json['domains']);
}
/**
* Test fetching plans for signup
*/
public function testSignupPlans(): void
{
$individual = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$group = Plan::withEnvTenantContext()->where('title', 'group')->first();
$response = $this->get('/api/auth/signup/plans');
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertCount(2, $json['plans']);
$this->assertSame($individual->title, $json['plans'][0]['title']);
$this->assertSame($individual->name, $json['plans'][0]['name']);
$this->assertSame($individual->description, $json['plans'][0]['description']);
$this->assertFalse($json['plans'][0]['isDomain']);
$this->assertArrayHasKey('button', $json['plans'][0]);
$this->assertSame($group->title, $json['plans'][1]['title']);
$this->assertSame($group->name, $json['plans'][1]['name']);
$this->assertSame($group->description, $json['plans'][1]['description']);
$this->assertTrue($json['plans'][1]['isDomain']);
$this->assertArrayHasKey('button', $json['plans'][1]);
}
/**
* Test fetching invitation
*/
public function testSignupInvitations(): void
{
Queue::fake();
$invitation = SI::create(['email' => 'email1@ext.com']);
// Test existing invitation
$response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($invitation->id, $json['id']);
// Test non-existing invitation
$response = $this->get("/api/auth/signup/invitations/abc");
$response->assertStatus(404);
// Test completed invitation
SI::where('id', $invitation->id)->update(['status' => SI::STATUS_COMPLETED]);
$response = $this->get("/api/auth/signup/invitations/{$invitation->id}");
$response->assertStatus(404);
}
/**
* Test signup initialization with invalid input
*/
public function testSignupInitInvalidInput(): void
{
// Empty input data
$data = [];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
// Data with missing name
$data = [
'email' => 'UsersApiControllerTest1@UsersApiControllerTest.com',
'first_name' => str_repeat('a', 250),
'last_name' => str_repeat('a', 250),
];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('first_name', $json['errors']);
$this->assertArrayHasKey('last_name', $json['errors']);
// Data with invalid email (but not phone number)
$data = [
'email' => '@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('email', $json['errors']);
// Sanity check on voucher code, last/first name is optional
$data = [
'voucher' => '123456789012345678901234567890123',
'email' => 'valid@email.com',
];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('voucher', $json['errors']);
// Email address too long
$data = [
'email' => str_repeat('a', 190) . '@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
];
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame(["The specified email address is invalid."], $json['errors']['email']);
SignupCode::truncate();
// Email address limit check
$data = [
'email' => 'test@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
];
\config(['app.signup.email_limit' => 0]);
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(200);
\config(['app.signup.email_limit' => 1]);
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
// TODO: This probably should be a different message?
$this->assertSame(["The specified email address is invalid."], $json['errors']['email']);
// IP address limit check
$data = [
'email' => 'ip@example.org',
'first_name' => 'Signup',
'last_name' => 'User',
];
\config(['app.signup.email_limit' => 0]);
\config(['app.signup.ip_limit' => 0]);
$response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']);
$json = $response->json();
$response->assertStatus(200);
\config(['app.signup.ip_limit' => 1]);
$response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.1']);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
// TODO: This probably should be a different message?
$this->assertSame(["The specified email address is invalid."], $json['errors']['email']);
// TODO: Test phone validation
}
/**
* Test signup initialization with valid input
*/
public function testSignupInitValidInput(): array
{
Queue::fake();
// Assert that no jobs were pushed...
Queue::assertNothingPushed();
$data = [
'email' => 'testuser@external.com',
'first_name' => 'Signup',
'last_name' => 'User',
'plan' => 'individual',
];
$response = $this->post('/api/auth/signup/init', $data, ['REMOTE_ADDR' => '10.1.1.2']);
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(3, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
$this->assertNotEmpty($json['code']);
$code = SignupCode::find($json['code']);
$this->assertSame('10.1.1.2', $code->ip_address);
$this->assertSame(null, $code->verify_ip_address);
$this->assertSame(null, $code->submit_ip_address);
// Assert the email sending job was pushed once
Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
// Assert the job has proper data assigned
Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
$code = TestCase::getObjectProperty($job, 'code');
return $code->code === $json['code']
&& $code->plan === $data['plan']
&& $code->email === $data['email']
&& $code->first_name === $data['first_name']
&& $code->last_name === $data['last_name'];
});
// Try the same with voucher
$data['voucher'] = 'TEST';
$response = $this->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(3, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
$this->assertNotEmpty($json['code']);
// Assert the job has proper data assigned
Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
$code = TestCase::getObjectProperty($job, 'code');
return $code->code === $json['code']
&& $code->plan === $data['plan']
&& $code->email === $data['email']
&& $code->voucher === $data['voucher']
&& $code->first_name === $data['first_name']
&& $code->last_name === $data['last_name'];
});
return [
'code' => $json['code'],
'email' => $data['email'],
'first_name' => $data['first_name'],
'last_name' => $data['last_name'],
'plan' => $data['plan'],
'voucher' => $data['voucher']
];
}
/**
* Test signup code verification with invalid input
*
* @depends testSignupInitValidInput
*/
public function testSignupVerifyInvalidInput(array $result): void
{
// Empty data
$data = [];
$response = $this->post('/api/auth/signup/verify', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('code', $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
// Data with existing code but missing short_code
$data = [
'code' => $result['code'],
];
$response = $this->post('/api/auth/signup/verify', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
// Data with invalid short_code
$data = [
'code' => $result['code'],
'short_code' => 'XXXX',
];
$response = $this->post('/api/auth/signup/verify', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
// TODO: Test expired code
}
/**
* Test signup code verification with valid input
*
* @depends testSignupInitValidInput
*/
public function testSignupVerifyValidInput(array $result): array
{
$code = SignupCode::find($result['code']);
$code->ip_address = '10.1.1.2';
$code->save();
$data = [
'code' => $code->code,
'short_code' => $code->short_code,
];
$response = $this->post('/api/auth/signup/verify', $data, ['REMOTE_ADDR' => '10.1.1.3']);
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(7, $json);
$this->assertSame('success', $json['status']);
$this->assertSame($result['email'], $json['email']);
$this->assertSame($result['first_name'], $json['first_name']);
$this->assertSame($result['last_name'], $json['last_name']);
$this->assertSame($result['voucher'], $json['voucher']);
$this->assertSame(false, $json['is_domain']);
$this->assertTrue(is_array($json['domains']) && !empty($json['domains']));
$code->refresh();
$this->assertSame('10.1.1.2', $code->ip_address);
$this->assertSame('10.1.1.3', $code->verify_ip_address);
$this->assertSame(null, $code->submit_ip_address);
return $result;
}
/**
* Test last signup step with invalid input
*
* @depends testSignupVerifyValidInput
*/
public function testSignupInvalidInput(array $result): void
{
// Empty data
$data = [];
$response = $this->post('/api/auth/signup', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(3, $json['errors']);
$this->assertArrayHasKey('login', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
$this->assertArrayHasKey('domain', $json['errors']);
$domain = $this->getPublicDomain();
// Passwords do not match and missing domain
$data = [
'login' => 'test',
'password' => 'test',
'password_confirmation' => 'test2',
];
$response = $this->post('/api/auth/signup', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
$this->assertArrayHasKey('domain', $json['errors']);
$domain = $this->getPublicDomain();
// Login too short, password too short
$data = [
'login' => '1',
'domain' => $domain,
'password' => 'test',
'password_confirmation' => 'test',
];
$response = $this->post('/api/auth/signup', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('login', $json['errors']);
$this->assertArrayHasKey('password', $json['errors']);
// Missing codes
$data = [
'login' => 'login-valid',
'domain' => $domain,
'password' => 'testtest',
'password_confirmation' => 'testtest',
];
$response = $this->post('/api/auth/signup', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertArrayHasKey('code', $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
// Data with invalid short_code
$data = [
'login' => 'TestLogin',
'domain' => $domain,
'password' => 'testtest',
'password_confirmation' => 'testtest',
'code' => $result['code'],
'short_code' => 'XXXX',
];
$response = $this->post('/api/auth/signup', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('short_code', $json['errors']);
$code = SignupCode::find($result['code']);
// Data with invalid voucher
$data = [
'login' => 'TestLogin',
'domain' => $domain,
'password' => 'testtest',
'password_confirmation' => 'testtest',
'code' => $result['code'],
'short_code' => $code->short_code,
'voucher' => 'XXX',
];
$response = $this->post('/api/auth/signup', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('voucher', $json['errors']);
// Valid code, invalid login
$data = [
'login' => 'żżżżżż',
'domain' => $domain,
'password' => 'testtest',
'password_confirmation' => 'testtest',
'code' => $result['code'],
'short_code' => $code->short_code,
];
$response = $this->post('/api/auth/signup', $data);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertArrayHasKey('login', $json['errors']);
}
/**
* Test last signup step with valid input (user creation)
*
* @depends testSignupVerifyValidInput
*/
public function testSignupValidInput(array $result): void
{
$queue = Queue::fake();
$domain = $this->getPublicDomain();
$identity = \strtolower('SignupLogin@') . $domain;
$code = SignupCode::find($result['code']);
$code->ip_address = '10.1.1.2';
$code->verify_ip_address = '10.1.1.3';
$code->save();
$data = [
'login' => 'SignupLogin',
'domain' => $domain,
'password' => 'testtest',
'password_confirmation' => 'testtest',
'code' => $code->code,
'short_code' => $code->short_code,
'voucher' => 'TEST',
];
$response = $this->post('/api/auth/signup', $data, ['REMOTE_ADDR' => '10.1.1.4']);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame('bearer', $json['token_type']);
$this->assertTrue(!empty($json['expires_in']) && is_int($json['expires_in']) && $json['expires_in'] > 0);
$this->assertNotEmpty($json['access_token']);
$this->assertSame($identity, $json['email']);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($data) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userEmail === \strtolower($data['login'] . '@' . $data['domain']);
}
);
$code->refresh();
// Check if the user has been created
$user = User::where('email', $identity)->first();
$this->assertNotEmpty($user);
$this->assertSame($identity, $user->email);
$this->assertTrue($user->isRestricted());
// Check if the code has been updated and soft-deleted
$this->assertTrue($code->trashed());
$this->assertSame('10.1.1.2', $code->ip_address);
$this->assertSame('10.1.1.3', $code->verify_ip_address);
$this->assertSame('10.1.1.4', $code->submit_ip_address);
$this->assertSame($user->id, $code->user_id);
// Check user settings
$this->assertSame($result['first_name'], $user->getSetting('first_name'));
$this->assertSame($result['last_name'], $user->getSetting('last_name'));
$this->assertSame($result['email'], $user->getSetting('external_email'));
// Discount
$discount = Discount::where('code', 'TEST')->first();
$this->assertSame($discount->id, $user->wallets()->first()->discount_id);
// TODO: Check SKUs/Plan
// TODO: Check if the access token works
}
/**
* Test signup for a group (custom domain) account
*/
public function testSignupGroupAccount(): void
{
Queue::fake();
// Initial signup request
$user_data = $data = [
'email' => 'testuser@external.com',
'first_name' => 'Signup',
'last_name' => 'User',
'plan' => 'group',
];
$response = $this->withoutMiddleware()->post('/api/auth/signup/init', $data);
$json = $response->json();
$response->assertStatus(200);
$this->assertCount(3, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('email', $json['mode']);
$this->assertNotEmpty($json['code']);
// Assert the email sending job was pushed once
Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, 1);
// Assert the job has proper data assigned
Queue::assertPushed(\App\Jobs\SignupVerificationEmail::class, function ($job) use ($data, $json) {
$code = TestCase::getObjectProperty($job, 'code');
return $code->code === $json['code']
&& $code->plan === $data['plan']
&& $code->email === $data['email']
&& $code->first_name === $data['first_name']
&& $code->last_name === $data['last_name'];
});
// Verify the code
$code = SignupCode::find($json['code']);
$data = [
'code' => $code->code,
'short_code' => $code->short_code,
];
$response = $this->post('/api/auth/signup/verify', $data);
$result = $response->json();
$response->assertStatus(200);
$this->assertCount(7, $result);
$this->assertSame('success', $result['status']);
$this->assertSame($user_data['email'], $result['email']);
$this->assertSame($user_data['first_name'], $result['first_name']);
$this->assertSame($user_data['last_name'], $result['last_name']);
$this->assertSame(null, $result['voucher']);
$this->assertSame(true, $result['is_domain']);
$this->assertSame([], $result['domains']);
// Final signup request
$login = 'admin';
$domain = 'external.com';
$data = [
'login' => $login,
'domain' => $domain,
'password' => 'testtest',
'password_confirmation' => 'testtest',
'code' => $code->code,
'short_code' => $code->short_code,
];
$response = $this->post('/api/auth/signup', $data);
$result = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $result['status']);
$this->assertSame('bearer', $result['token_type']);
$this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0);
$this->assertNotEmpty($result['access_token']);
$this->assertSame("$login@$domain", $result['email']);
Queue::assertPushed(\App\Jobs\Domain\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\Domain\CreateJob::class,
function ($job) use ($domain) {
$domainNamespace = TestCase::getObjectProperty($job, 'domainNamespace');
return $domainNamespace === $domain;
}
);
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
Queue::assertPushed(
\App\Jobs\User\CreateJob::class,
function ($job) use ($data) {
$userEmail = TestCase::getObjectProperty($job, 'userEmail');
return $userEmail === $data['login'] . '@' . $data['domain'];
}
);
// Check if the code has been removed
$code->refresh();
$this->assertTrue($code->trashed());
// Check if the user has been created
$user = User::where('email', $login . '@' . $domain)->first();
$this->assertNotEmpty($user);
$this->assertTrue($user->isRestricted());
// Check user settings
$this->assertSame($user_data['email'], $user->getSetting('external_email'));
$this->assertSame($user_data['first_name'], $user->getSetting('first_name'));
$this->assertSame($user_data['last_name'], $user->getSetting('last_name'));
// TODO: Check domain record
// TODO: Check SKUs/Plan
// TODO: Check if the access token works
}
/**
* Test signup with mode=mandate
*/
public function testSignupMandateMode(): void
{
Queue::fake();
$plan = Plan::create([
'title' => 'test',
'name' => 'Test Account',
'description' => 'Test',
'free_months' => 1,
'discount_qty' => 0,
'discount_rate' => 0,
- 'mode' => 'mandate',
+ 'mode' => Plan::MODE_MANDATE,
]);
$packages = [
Package::where(['title' => 'kolab', 'tenant_id' => \config('app.tenant_id')])->first()
];
$plan->packages()->saveMany($packages);
$post = [
'plan' => 'abc',
'login' => 'test-inv',
'domain' => 'kolabnow.com',
'password' => 'testtest',
'password_confirmation' => 'testtest',
];
// Test invalid plan identifier
$response = $this->post('/api/auth/signup', $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The selected plan is invalid.", $json['errors']['plan']);
// Test valid input
$post['plan'] = $plan->title;
$response = $this->post('/api/auth/signup', $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertNotEmpty($json['access_token']);
$this->assertSame('test-inv@kolabnow.com', $json['email']);
$this->assertTrue($json['isLocked']);
$user = User::where('email', 'test-inv@kolabnow.com')->first();
$this->assertNotEmpty($user);
$this->assertSame($plan->id, $user->getSetting('plan_id'));
}
/**
* Test signup via invitation
*/
public function testSignupViaInvitation(): void
{
Queue::fake();
$invitation = SI::create(['email' => 'email1@ext.com']);
$post = [
'invitation' => 'abc',
'first_name' => 'Signup',
'last_name' => 'User',
'login' => 'test-inv',
'domain' => 'kolabnow.com',
'password' => 'testtest',
'password_confirmation' => 'testtest',
];
// Test invalid invitation identifier
$response = $this->post('/api/auth/signup', $post);
$response->assertStatus(404);
// Test valid input
$post['invitation'] = $invitation->id;
$response = $this->post('/api/auth/signup', $post);
$result = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $result['status']);
$this->assertSame('bearer', $result['token_type']);
$this->assertTrue(!empty($result['expires_in']) && is_int($result['expires_in']) && $result['expires_in'] > 0);
$this->assertNotEmpty($result['access_token']);
$this->assertSame('test-inv@kolabnow.com', $result['email']);
// Check if the user has been created
$user = User::where('email', 'test-inv@kolabnow.com')->first();
$this->assertNotEmpty($user);
// Check user settings
$this->assertSame($invitation->email, $user->getSetting('external_email'));
$this->assertSame($post['first_name'], $user->getSetting('first_name'));
$this->assertSame($post['last_name'], $user->getSetting('last_name'));
$invitation->refresh();
$this->assertSame($user->id, $invitation->user_id);
$this->assertTrue($invitation->isCompleted());
// TODO: Test POST params validation
}
/**
* List of login/domain validation cases for testValidateLogin()
*
* @return array Arguments for testValidateLogin()
*/
public function dataValidateLogin(): array
{
$domain = $this->getPublicDomain();
return [
// Individual account
['', $domain, false, ['login' => 'The login field is required.']],
['test123456', 'localhost', false, ['domain' => 'The specified domain is invalid.']],
['test123456', 'unknown-domain.org', false, ['domain' => 'The specified domain is invalid.']],
['test.test', $domain, false, null],
['test_test', $domain, false, null],
['test-test', $domain, false, null],
['admin', $domain, false, ['login' => 'The specified login is not available.']],
['administrator', $domain, false, ['login' => 'The specified login is not available.']],
['sales', $domain, false, ['login' => 'The specified login is not available.']],
['root', $domain, false, ['login' => 'The specified login is not available.']],
// Domain account
['admin', 'kolabsys.com', true, null],
['testnonsystemdomain', 'invalid', true, ['domain' => 'The specified domain is invalid.']],
['testnonsystemdomain', '.com', true, ['domain' => 'The specified domain is invalid.']],
];
}
/**
* Signup login/domain validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*
* @dataProvider dataValidateLogin
*/
public function testValidateLogin($login, $domain, $external, $expected_result): void
{
$result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
$this->assertSame($expected_result, $result);
}
/**
* Signup login/domain validation, more cases
*/
public function testValidateLoginMore(): void
{
Queue::fake();
// Test registering for an email of an existing group
$login = 'group-test';
$domain = 'kolabnow.com';
$group = $this->getTestGroup("{$login}@{$domain}");
$external = false;
$result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
$this->assertSame(['login' => 'The specified login is not available.'], $result);
// Test registering for an email of an existing, but soft-deleted group
$group->delete();
$result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
$this->assertSame(['login' => 'The specified login is not available.'], $result);
// Test registering for an email of an existing user
$domain = $this->getPublicDomain();
$login = 'signuplogin';
$user = $this->getTestUser("{$login}@{$domain}");
$external = false;
$result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
$this->assertSame(['login' => 'The specified login is not available.'], $result);
// Test registering for an email of an existing, but soft-deleted user
$user->delete();
$result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain, $external]);
$this->assertSame(['login' => 'The specified login is not available.'], $result);
// Test registering for a domain that exists
$external = true;
$domain = $this->getTestDomain(
'external.com',
['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
);
$result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain->namespace, $external]);
$this->assertSame(['domain' => 'The specified domain is not available.'], $result);
// Test registering for a domain that exists but is soft-deleted
$domain->delete();
$result = $this->invokeMethod(new SignupController(), 'validateLogin', [$login, $domain->namespace, $external]);
$this->assertSame(['domain' => 'The specified domain is not available.'], $result);
}
}
diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php
index 919802e8..8a7fbd28 100644
--- a/src/tests/Feature/Controller/UsersTest.php
+++ b/src/tests/Feature/Controller/UsersTest.php
@@ -1,1718 +1,1717 @@
<?php
namespace Tests\Feature\Controller;
use App\Discount;
use App\Domain;
use App\Http\Controllers\API\V4\UsersController;
use App\Package;
use App\Plan;
use App\Sku;
use App\Tenant;
use App\User;
use App\Wallet;
use Carbon\Carbon;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
class UsersTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->clearBetaEntitlements();
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$this->deleteTestSharedFolder('folder-test@kolabnow.com');
$this->deleteTestResource('resource-test@kolabnow.com');
Sku::where('title', 'test')->delete();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->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]);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->clearBetaEntitlements();
$this->deleteTestUser('jane@kolabnow.com');
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('UsersControllerTest2@userscontroller.com');
$this->deleteTestUser('UsersControllerTest3@userscontroller.com');
$this->deleteTestUser('UserEntitlement2A@UserEntitlement.com');
$this->deleteTestUser('john2.doe2@kolab.org');
$this->deleteTestUser('deleted@kolab.org');
$this->deleteTestUser('deleted@kolabnow.com');
$this->deleteTestDomain('userscontroller.com');
$this->deleteTestGroup('group-test@kolabnow.com');
$this->deleteTestGroup('group-test@kolab.org');
$this->deleteTestSharedFolder('folder-test@kolabnow.com');
$this->deleteTestResource('resource-test@kolabnow.com');
Sku::where('title', 'test')->delete();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->discount()->dissociate();
$wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete();
$wallet->save();
$user->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]);
parent::tearDown();
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroy(): void
{
// First create some users/accounts to delete
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$john = $this->getTestUser('john@kolab.org');
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
// Test unauth access
$response = $this->delete("api/v4/users/{$user2->id}");
$response->assertStatus(401);
// Test access to other user/account
$response = $this->actingAs($john)->delete("api/v4/users/{$user2->id}");
$response->assertStatus(403);
$response = $this->actingAs($john)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(403);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test that non-controller cannot remove himself
$response = $this->actingAs($user3)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(403);
// Test removing a non-controller user
$response = $this->actingAs($user1)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
// Test removing self (an account with users)
$response = $this->actingAs($user1)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals('success', $json['status']);
$this->assertEquals('User deleted successfully.', $json['message']);
}
/**
* Test user deleting (DELETE /api/v4/users/<id>)
*/
public function testDestroyByController(): void
{
// Create an account with additional controller - $user2
$package_kolab = \App\Package::where('title', 'kolab')->first();
$package_domain = \App\Package::where('title', 'domain-hosting')->first();
$user1 = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user2 = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$user3 = $this->getTestUser('UsersControllerTest3@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user1->assignPackage($package_kolab);
$domain->assignPackage($package_domain, $user1);
$user1->assignPackage($package_kolab, $user2);
$user1->assignPackage($package_kolab, $user3);
$user1->wallets()->first()->addController($user2);
// TODO/FIXME:
// For now controller can delete himself, as well as
// the whole account he has control to, including the owner
// Probably he should not be able to do none of those
// However, this is not 0-regression scenario as we
// do not fully support additional controllers.
//$response = $this->actingAs($user2)->delete("api/v4/users/{$user2->id}");
//$response->assertStatus(403);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user3->id}");
$response->assertStatus(200);
$response = $this->actingAs($user2)->delete("api/v4/users/{$user1->id}");
$response->assertStatus(200);
// Note: More detailed assertions in testDestroy() above
$this->assertTrue($user1->fresh()->trashed());
$this->assertTrue($user2->fresh()->trashed());
$this->assertTrue($user3->fresh()->trashed());
}
/**
* Test user listing (GET /api/v4/users)
*/
public function testIndex(): void
{
// Test unauth access
$response = $this->get("api/v4/users");
$response->assertStatus(401);
$jack = $this->getTestUser('jack@kolab.org');
$joe = $this->getTestUser('joe@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($jack)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($john)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
$this->assertSame($joe->email, $json['list'][1]['email']);
$this->assertSame($john->email, $json['list'][2]['email']);
$this->assertSame($ned->email, $json['list'][3]['email']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json['list'][0]);
$this->assertArrayHasKey('isDegraded', $json['list'][0]);
$this->assertArrayHasKey('isAccountDegraded', $json['list'][0]);
$this->assertArrayHasKey('isSuspended', $json['list'][0]);
$this->assertArrayHasKey('isActive', $json['list'][0]);
$this->assertArrayHasKey('isReady', $json['list'][0]);
$this->assertArrayHasKey('isImapReady', $json['list'][0]);
if (\config('app.with_ldap')) {
$this->assertArrayHasKey('isLdapReady', $json['list'][0]);
} else {
$this->assertArrayNotHasKey('isLdapReady', $json['list'][0]);
}
$response = $this->actingAs($ned)->get("/api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
$this->assertSame($joe->email, $json['list'][1]['email']);
$this->assertSame($john->email, $json['list'][2]['email']);
$this->assertSame($ned->email, $json['list'][3]['email']);
// Search by user email
$response = $this->actingAs($john)->get("/api/v4/users?search=jack@k");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($jack->email, $json['list'][0]['email']);
// Search by alias
$response = $this->actingAs($john)->get("/api/v4/users?search=monster");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($joe->email, $json['list'][0]['email']);
// Search by name
$response = $this->actingAs($john)->get("/api/v4/users?search=land");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasMore']);
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($ned->email, $json['list'][0]['email']);
// TODO: Test paging
}
/**
* Test fetching user data/profile (GET /api/v4/users/<user-id>)
*/
public function testShow(): void
{
$userA = $this->getTestUser('UserEntitlement2A@UserEntitlement.com');
// Test getting profile of self
$response = $this->actingAs($userA)->get("/api/v4/users/{$userA->id}");
$json = $response->json();
$response->assertStatus(200);
$this->assertEquals($userA->id, $json['id']);
$this->assertEquals($userA->email, $json['email']);
$this->assertTrue(is_array($json['statusInfo']));
$this->assertTrue(is_array($json['settings']));
$this->assertTrue($json['config']['greylist_enabled']);
$this->assertFalse($json['config']['guam_enabled']);
$this->assertSame([], $json['skus']);
$this->assertSame([], $json['aliases']);
// Values below are tested by Unit tests
$this->assertArrayHasKey('isDeleted', $json);
$this->assertArrayHasKey('isDegraded', $json);
$this->assertArrayHasKey('isAccountDegraded', $json);
$this->assertArrayHasKey('isSuspended', $json);
$this->assertArrayHasKey('isActive', $json);
$this->assertArrayHasKey('isReady', $json);
$this->assertArrayHasKey('isImapReady', $json);
if (\config('app.with_ldap')) {
$this->assertArrayHasKey('isLdapReady', $json);
} else {
$this->assertArrayNotHasKey('isLdapReady', $json);
}
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
// Test unauthorized access to a profile of other user
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}");
$response->assertStatus(403);
// Test authorized access to a profile of other user
// Ned: Additional account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(['john.doe@kolab.org'], $json['aliases']);
$response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
// John: Account owner
$response = $this->actingAs($john)->get("/api/v4/users/{$jack->id}");
$response->assertStatus(200);
$response = $this->actingAs($john)->get("/api/v4/users/{$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$storage_sku = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$groupware_sku = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox_sku = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$secondfactor_sku = Sku::withEnvTenantContext()->where('title', '2fa')->first();
$this->assertCount(5, $json['skus']);
$this->assertSame(5, $json['skus'][$storage_sku->id]['count']);
$this->assertSame([0,0,0,0,0], $json['skus'][$storage_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$groupware_sku->id]['count']);
$this->assertSame([490], $json['skus'][$groupware_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']);
$this->assertSame([500], $json['skus'][$mailbox_sku->id]['costs']);
$this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']);
$this->assertSame([0], $json['skus'][$secondfactor_sku->id]['costs']);
$this->assertSame([], $json['aliases']);
}
/**
* Test fetching SKUs list for a user (GET /users/<id>/skus)
*/
public function testSkus(): void
{
$user = $this->getTestUser('john@kolab.org');
// Unauth access not allowed
$response = $this->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(401);
// Create an sku for another tenant, to make sure it is not included in the result
$nsku = Sku::create([
'title' => 'test',
'name' => 'Test',
'description' => '',
'active' => true,
'cost' => 100,
'handler_class' => 'Mailbox',
]);
$tenant = Tenant::whereNotIn('id', [\config('app.tenant_id')])->first();
$nsku->tenant_id = $tenant->id;
$nsku->save();
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSkuElement('mailbox', $json[0], [
'prio' => 100,
'type' => 'user',
'handler' => 'Mailbox',
'enabled' => true,
'readonly' => true,
]);
$this->assertSkuElement('storage', $json[1], [
'prio' => 90,
'type' => 'user',
'handler' => 'Storage',
'enabled' => true,
'readonly' => true,
'range' => [
'min' => 5,
'max' => 100,
'unit' => 'GB',
]
]);
$this->assertSkuElement('groupware', $json[2], [
'prio' => 80,
'type' => 'user',
'handler' => 'Groupware',
'enabled' => false,
'readonly' => false,
]);
$this->assertSkuElement('activesync', $json[3], [
'prio' => 70,
'type' => 'user',
'handler' => 'Activesync',
'enabled' => false,
'readonly' => false,
'required' => ['Groupware'],
]);
$this->assertSkuElement('2fa', $json[4], [
'prio' => 60,
'type' => 'user',
'handler' => 'Auth2F',
'enabled' => false,
'readonly' => false,
'forbidden' => ['Activesync'],
]);
// Test inclusion of beta SKUs
$sku = Sku::withEnvTenantContext()->where('title', 'beta')->first();
$user->assignSku($sku);
$response = $this->actingAs($user)->get("api/v4/users/{$user->id}/skus");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(6, $json);
$this->assertSkuElement('beta', $json[5], [
'prio' => 10,
'type' => 'user',
'handler' => 'Beta',
'enabled' => false,
'readonly' => false,
]);
}
/**
* Test fetching user status (GET /api/v4/users/<user-id>/status)
* and forcing setup process update (?refresh=1)
*
* @group imap
* @group dns
*/
public function testStatus(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
// Test unauthorized access
$response = $this->actingAs($jack)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(403);
$john->status &= ~User::STATUS_IMAP_READY;
$john->status &= ~User::STATUS_LDAP_READY;
$john->save();
// Get user status
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isReady']);
$this->assertFalse($json['isImapReady']);
$this->assertTrue(empty($json['status']));
$this->assertTrue(empty($json['message']));
if (\config('app.with_ldap')) {
$this->assertFalse($json['isLdapReady']);
$this->assertSame('user-ldap-ready', $json['process'][1]['label']);
$this->assertFalse($json['process'][1]['state']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertFalse($json['process'][2]['state']);
} else {
$this->assertArrayNotHasKey('isLdapReady', $json);
$this->assertSame('user-imap-ready', $json['process'][1]['label']);
$this->assertFalse($json['process'][1]['state']);
}
// Make sure the domain is confirmed (other test might unset that status)
$domain = $this->getTestDomain('kolab.org');
$domain->status |= Domain::STATUS_CONFIRMED;
$domain->save();
// Now "reboot" the process
Queue::fake();
$response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse($json['isImapReady']);
$this->assertFalse($json['isReady']);
$this->assertSame('success', $json['status']);
$this->assertSame('Setup process has been pushed. Please wait.', $json['message']);
if (\config('app.with_ldap')) {
$this->assertFalse($json['isLdapReady']);
$this->assertSame('user-ldap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
$this->assertSame('user-imap-ready', $json['process'][2]['label']);
$this->assertSame(false, $json['process'][2]['state']);
} else {
$this->assertSame('user-imap-ready', $json['process'][1]['label']);
$this->assertSame(false, $json['process'][1]['state']);
}
Queue::assertPushed(\App\Jobs\User\CreateJob::class, 1);
}
/**
* Test UsersController::statusInfo()
*/
public function testStatusInfo(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain('userscontroller.com', [
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_PUBLIC,
]);
$user->created_at = Carbon::now();
$user->status = User::STATUS_NEW;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isDone']);
$this->assertSame([], $result['skus']);
$this->assertCount(3, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
if (\config('app.with_ldap')) {
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(false, $result['process'][2]['state']);
} else {
$this->assertSame('user-imap-ready', $result['process'][1]['label']);
$this->assertSame(false, $result['process'][1]['state']);
}
$this->assertSame('running', $result['processState']);
$this->assertTrue($result['enableRooms']);
$this->assertFalse($result['enableBeta']);
$user->created_at = Carbon::now()->subSeconds(181);
$user->save();
$result = UsersController::statusInfo($user);
$this->assertSame('failed', $result['processState']);
$user->status |= User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['isDone']);
$this->assertCount(3, $result['process']);
$this->assertSame('done', $result['processState']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
if (\config('app.with_ldap')) {
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
} else {
$this->assertSame('user-imap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
}
$domain->status |= Domain::STATUS_VERIFIED;
$domain->type = Domain::TYPE_EXTERNAL;
$domain->save();
$result = UsersController::statusInfo($user);
$this->assertFalse($result['isDone']);
$this->assertSame([], $result['skus']);
$this->assertCount(7, $result['process']);
$this->assertSame('user-new', $result['process'][0]['label']);
$this->assertSame(true, $result['process'][0]['state']);
if (\config('app.with_ldap')) {
$this->assertSame('user-ldap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('user-imap-ready', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-new', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-ldap-ready', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
$this->assertSame('domain-verified', $result['process'][5]['label']);
$this->assertSame(true, $result['process'][5]['state']);
$this->assertSame('domain-confirmed', $result['process'][6]['label']);
$this->assertSame(false, $result['process'][6]['state']);
} else {
$this->assertSame('user-imap-ready', $result['process'][1]['label']);
$this->assertSame(true, $result['process'][1]['state']);
$this->assertSame('domain-new', $result['process'][2]['label']);
$this->assertSame(true, $result['process'][2]['state']);
$this->assertSame('domain-verified', $result['process'][3]['label']);
$this->assertSame(true, $result['process'][3]['state']);
$this->assertSame('domain-confirmed', $result['process'][4]['label']);
$this->assertSame(false, $result['process'][4]['state']);
}
// Test 'skus' property
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'beta')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta'], $result['skus']);
$this->assertTrue($result['enableBeta']);
$user->assignSku(Sku::withEnvTenantContext()->where('title', 'groupware')->first());
$result = UsersController::statusInfo($user);
$this->assertSame(['beta', 'groupware'], $result['skus']);
// Degraded user
$user->status |= User::STATUS_DEGRADED;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['enableBeta']);
$this->assertFalse($result['enableRooms']);
// User in a tenant without 'room' SKU
$user->status = User::STATUS_LDAP_READY | User::STATUS_IMAP_READY | User::STATUS_ACTIVE;
$user->tenant_id = Tenant::where('title', 'Sample Tenant')->first()->id;
$user->save();
$result = UsersController::statusInfo($user);
$this->assertTrue($result['enableBeta']);
$this->assertFalse($result['enableRooms']);
}
/**
* Test user config update (POST /api/v4/users/<user>/config)
*/
public function testSetConfig(): void
{
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('greylist_enabled', null);
$john->setSetting('guam_enabled', null);
$john->setSetting('password_policy', null);
$john->setSetting('max_password_age', null);
// Test unknown user id
$post = ['greylist_enabled' => 1];
$response = $this->actingAs($john)->post("/api/v4/users/123/config", $post);
$json = $response->json();
$response->assertStatus(404);
// Test access by user not being a wallet controller
$post = ['greylist_enabled' => 1];
$response = $this->actingAs($jack)->post("/api/v4/users/{$john->id}/config", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['grey' => 1, 'password_policy' => 'min:1,max:255'];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertCount(2, $json['errors']);
$this->assertSame("The requested configuration parameter is not supported.", $json['errors']['grey']);
$this->assertSame("Minimum password length cannot be less than 6.", $json['errors']['password_policy']);
$this->assertNull($john->fresh()->getSetting('greylist_enabled'));
// Test some valid data
$post = [
'greylist_enabled' => 1,
'guam_enabled' => 1,
'password_policy' => 'min:10,max:255,upper,lower,digit,special',
'max_password_age' => 6,
];
$response = $this->actingAs($john)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('true', $john->getSetting('greylist_enabled'));
$this->assertSame('true', $john->getSetting('guam_enabled'));
$this->assertSame('min:10,max:255,upper,lower,digit,special', $john->getSetting('password_policy'));
$this->assertSame('6', $john->getSetting('max_password_age'));
// Test some valid data, acting as another account controller
$ned = $this->getTestUser('ned@kolab.org');
$post = ['greylist_enabled' => 0, 'guam_enabled' => 0, 'password_policy' => 'min:10,max:255,upper,last:1'];
$response = $this->actingAs($ned)->post("/api/v4/users/{$john->id}/config", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(2, $json);
$this->assertSame('success', $json['status']);
$this->assertSame('User settings updated successfully.', $json['message']);
$this->assertSame('false', $john->fresh()->getSetting('greylist_enabled'));
$this->assertSame(null, $john->fresh()->getSetting('guam_enabled'));
$this->assertSame('min:10,max:255,upper,last:1', $john->fresh()->getSetting('password_policy'));
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
Queue::fake();
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$john->setSetting('password_policy', 'min:8,max:100,digit');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
// Test empty request
$response = $this->actingAs($john)->post("/api/v4/users", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The email field is required.", $json['errors']['email']);
$this->assertSame("The password field is required.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test access by user not being a wallet controller
$post = ['first_name' => 'Test'];
$response = $this->actingAs($jack)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(403);
$this->assertSame('error', $json['status']);
$this->assertSame("Access denied", $json['message']);
$this->assertCount(2, $json);
// Test some invalid data
$post = ['password' => '12345678', 'email' => 'invalid'];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The password confirmation does not match.', $json['errors']['password'][0]);
$this->assertSame('The specified email is invalid.', $json['errors']['email']);
// Test existing user email
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'jack.daniels@kolab.org',
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame('The specified email is not available.', $json['errors']['email']);
$package_kolab = \App\Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_domain = \App\Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'john2.doe2@kolab.org',
'organization' => 'TestOrg',
'aliases' => ['useralias1@kolab.org', 'deleted@kolab.org'],
];
// Missing package
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Package is required.", $json['errors']['package']);
$this->assertCount(2, $json);
// Invalid package
$post['package'] = $package_domain->id;
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Invalid package selected.", $json['errors']['package']);
$this->assertCount(2, $json);
// Test password policy checking
$post['package'] = $package_kolab->id;
$post['password'] = 'password';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
$this->assertCount(2, $json);
// Test password confirmation
$post['password_confirmation'] = 'password';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][0]);
$this->assertCount(2, $json);
// Test full and valid data
$post['password'] = 'password123';
$post['password_confirmation'] = 'password123';
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$this->assertFalse($user->isRestricted());
/** @var \App\UserAlias[] $aliases */
$aliases = $user->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('deleted@kolab.org', $aliases[0]->alias);
$this->assertSame('useralias1@kolab.org', $aliases[1]->alias);
// Assert the new user entitlements
$this->assertEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Assert the wallet to which the new user should be assigned to
$wallet = $user->wallet();
$this->assertSame($john->wallets->first()->id, $wallet->id);
// Attempt to create a user previously deleted
$user->delete();
$post['package'] = $package_kolab->id;
$post['aliases'] = [];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = User::where('email', 'john2.doe2@kolab.org')->first();
$this->assertInstanceOf(User::class, $user);
$this->assertSame('John2', $user->getSetting('first_name'));
$this->assertSame('Doe2', $user->getSetting('last_name'));
$this->assertSame('TestOrg', $user->getSetting('organization'));
$this->assertCount(0, $user->aliases()->get());
$this->assertEntitlements($user, ['groupware', 'mailbox',
'storage', 'storage', 'storage', 'storage', 'storage']);
// Test password reset link "mode"
$code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
$john->verificationcodes()->save($code);
$post = [
'first_name' => 'John2',
'last_name' => 'Doe2',
'email' => 'deleted@kolab.org',
'organization' => '',
'aliases' => [],
'passwordLinkCode' => $code->short_code . '-' . $code->code,
'package' => $package_kolab->id,
];
$response = $this->actingAs($john)->post("/api/v4/users", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User created successfully.", $json['message']);
$this->assertCount(2, $json);
$user = $this->getTestUser('deleted@kolab.org');
$code->refresh();
$this->assertSame($user->id, $code->user_id);
$this->assertTrue($code->active);
$this->assertTrue(is_string($user->password) && strlen($user->password) >= 60);
// Test acting as account controller not owner, which is not yet supported
$john->wallets->first()->addController($user);
$response = $this->actingAs($user)->post("/api/v4/users", []);
$response->assertStatus(403);
// Test that creating a user in a restricted account creates a restricted user
$package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$domain = $this->getTestDomain(
'userscontroller.com',
['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
);
$domain->assignPackage($package_domain, $owner);
$owner->restrict();
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'email' => 'UsersControllerTest2@userscontroller.com',
'package' => $package_kolab->id,
];
$response = $this->actingAs($owner)->post("/api/v4/users", $post);
$response->assertStatus(200);
$user = User::where('email', 'UsersControllerTest1@userscontroller.com')->first();
$this->assertTrue($user->isRestricted());
}
/**
* Test user update (PUT /api/v4/users/<user-id>)
*/
public function testUpdate(): void
{
$userA = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$userA->setSetting('password_policy', 'min:8,digit');
$jack = $this->getTestUser('jack@kolab.org');
$john = $this->getTestUser('john@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$domain = $this->getTestDomain(
'userscontroller.com',
['status' => Domain::STATUS_NEW, 'type' => Domain::TYPE_EXTERNAL]
);
// Test unauthorized update of other user profile
$response = $this->actingAs($jack)->get("/api/v4/users/{$userA->id}", []);
$response->assertStatus(403);
// Test authorized update of account owner by account controller
$response = $this->actingAs($ned)->get("/api/v4/users/{$john->id}", []);
$response->assertStatus(200);
// Test updating of self (empty request)
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
// Test some invalid data
$post = ['password' => '1234567', 'currency' => 'invalid'];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
$this->assertSame("Specified password does not comply with the policy.", $json['errors']['password'][1]);
$this->assertSame("The currency must be 3 characters.", $json['errors']['currency'][0]);
// Test full profile update including password
$post = [
'password' => 'simple123',
'password_confirmation' => 'simple123',
'first_name' => 'John2',
'last_name' => 'Doe2',
'organization' => 'TestOrg',
'phone' => '+123 123 123',
'external_email' => 'external@gmail.com',
'billing_address' => 'billing',
'country' => 'CH',
'currency' => 'CHF',
'aliases' => ['useralias1@' . \config('app.domain'), 'useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
$this->assertTrue($userA->password != $userA->fresh()->password);
unset($post['password'], $post['password_confirmation'], $post['aliases']);
foreach ($post as $key => $value) {
$this->assertSame($value, $userA->getSetting($key));
}
$aliases = $userA->aliases()->orderBy('alias')->get();
$this->assertCount(2, $aliases);
$this->assertSame('useralias1@' . \config('app.domain'), $aliases[0]->alias);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[1]->alias);
// Test unsetting values
$post = [
'first_name' => '',
'last_name' => '',
'organization' => '',
'phone' => '',
'external_email' => '',
'billing_address' => '',
'country' => '',
'currency' => '',
'aliases' => ['useralias2@' . \config('app.domain')]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertTrue(!empty($json['statusInfo']));
$this->assertCount(3, $json);
unset($post['aliases']);
foreach ($post as $key => $value) {
$this->assertNull($userA->getSetting($key));
}
$aliases = $userA->aliases()->get();
$this->assertCount(1, $aliases);
$this->assertSame('useralias2@' . \config('app.domain'), $aliases[0]->alias);
// Test error on some invalid aliases missing password confirmation
$post = [
'password' => 'simple123',
'aliases' => [
'useralias2@' . \config('app.domain'),
'useralias1@kolab.org',
'@kolab.org',
]
];
$response = $this->actingAs($userA)->put("/api/v4/users/{$userA->id}", $post);
$json = $response->json();
$response->assertStatus(422);
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertCount(2, $json['errors']['aliases']);
$this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]);
$this->assertSame("The specified alias is invalid.", $json['errors']['aliases'][2]);
$this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]);
// Test authorized update of other user
$response = $this->actingAs($ned)->put("/api/v4/users/{$jack->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertTrue(empty($json['statusInfo']));
// TODO: Test error on aliases with invalid/non-existing/other-user's domain
// Create entitlements and additional user for following tests
$owner = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$user = $this->getTestUser('UsersControllerTest2@userscontroller.com');
$package_domain = Package::withEnvTenantContext()->where('title', 'domain-hosting')->first();
$package_kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$package_lite = Package::withEnvTenantContext()->where('title', 'lite')->first();
$sku_mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
$sku_storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$sku_groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$domain = $this->getTestDomain(
'userscontroller.com',
[
'status' => Domain::STATUS_NEW,
'type' => Domain::TYPE_EXTERNAL,
]
);
$domain->assignPackage($package_domain, $owner);
$owner->assignPackage($package_kolab);
$owner->assignPackage($package_lite, $user);
// Non-controller cannot update his own entitlements
$post = ['skus' => []];
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
// Test updating entitlements
$post = [
'skus' => [
$sku_mailbox->id => 1,
$sku_storage->id => 6,
$sku_groupware->id => 1,
],
];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$storage_cost = $user->entitlements()
->where('sku_id', $sku_storage->id)
->orderBy('cost')
->pluck('cost')->all();
$this->assertEntitlements(
$user,
['groupware', 'mailbox', 'storage', 'storage', 'storage', 'storage', 'storage', 'storage']
);
$this->assertSame([0, 0, 0, 0, 0, 25], $storage_cost);
$this->assertTrue(empty($json['statusInfo']));
// Test password reset link "mode"
$code = new \App\VerificationCode(['mode' => 'password-reset', 'active' => false]);
$owner->verificationcodes()->save($code);
$post = ['passwordLinkCode' => $code->short_code . '-' . $code->code];
$response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post);
$json = $response->json();
$response->assertStatus(200);
$code->refresh();
$this->assertSame($user->id, $code->user_id);
$this->assertTrue($code->active);
$this->assertSame($user->password, $user->fresh()->password);
}
/**
* Test UsersController::updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$jane = $this->getTestUser('jane@kolabnow.com');
$kolab = Package::withEnvTenantContext()->where('title', 'kolab')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$activesync = Sku::withEnvTenantContext()->where('title', 'activesync')->first();
$groupware = Sku::withEnvTenantContext()->where('title', 'groupware')->first();
$mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first();
// standard package, 1 mailbox, 1 groupware, 2 storage
$jane->assignPackage($kolab);
// add 2 storage, 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 7,
$activesync->id => 1
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'activesync',
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add 2 storage, remove 1 activesync
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// add mailbox
$post = [
'skus' => [
$mailbox->id => 2,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// remove mailbox
$post = [
'skus' => [
$mailbox->id => 0,
$groupware->id => 1,
$storage->id => 9,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(500);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
// less than free storage
$post = [
'skus' => [
$mailbox->id => 1,
$groupware->id => 1,
$storage->id => 1,
$activesync->id => 0
]
];
$response = $this->actingAs($jane)->put("/api/v4/users/{$jane->id}", $post);
$response->assertStatus(200);
$this->assertEntitlements(
$jane,
[
'groupware',
'mailbox',
'storage',
'storage',
'storage',
'storage',
'storage'
]
);
}
/**
* Test user data response used in show and info actions
*/
public function testUserResponse(): void
{
$provider = \config('services.payment_provider') ?: 'mollie';
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
$wallet->setSettings(['mollie_id' => null, 'stripe_id' => null]);
$wallet->owner->setSettings(['plan_id' => null]);
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$john]);
$this->assertEquals($john->id, $result['id']);
$this->assertEquals($john->email, $result['email']);
$this->assertEquals($john->status, $result['status']);
$this->assertTrue(is_array($result['statusInfo']));
$this->assertTrue(is_array($result['settings']));
$this->assertSame('US', $result['settings']['country']);
$this->assertSame('USD', $result['settings']['currency']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(0, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertArrayNotHasKey('discount', $result['wallet']);
$this->assertFalse($result['isLocked']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableWalletMandates']);
$this->assertTrue($result['statusInfo']['enableWalletPayments']);
$this->assertTrue($result['statusInfo']['enableUsers']);
$this->assertTrue($result['statusInfo']['enableSettings']);
// 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');
$ned_wallet = $ned->wallets()->first();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$ned]);
$this->assertEquals($ned->id, $result['id']);
$this->assertEquals($ned->email, $result['email']);
$this->assertTrue(is_array($result['accounts']));
$this->assertTrue(is_array($result['wallets']));
$this->assertCount(1, $result['accounts']);
$this->assertCount(1, $result['wallets']);
$this->assertSame($wallet->id, $result['wallet']['id']);
$this->assertSame($wallet->id, $result['accounts'][0]['id']);
$this->assertSame($ned_wallet->id, $result['wallets'][0]['id']);
$this->assertSame($provider, $result['wallet']['provider']);
$this->assertSame($provider, $result['wallets'][0]['provider']);
$this->assertFalse($result['isLocked']);
$this->assertTrue($result['statusInfo']['enableDomains']);
$this->assertTrue($result['statusInfo']['enableWallets']);
$this->assertTrue($result['statusInfo']['enableWalletMandates']);
$this->assertFalse($result['statusInfo']['enableWalletPayments']);
$this->assertTrue($result['statusInfo']['enableUsers']);
$this->assertTrue($result['statusInfo']['enableSettings']);
// Test discount in a response
$discount = Discount::where('code', 'TEST')->first();
$wallet->discount()->associate($discount);
$wallet->save();
$mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie';
$wallet->setSetting($mod_provider . '_id', 123);
$john->refresh();
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$john]);
$this->assertEquals($john->id, $result['id']);
$this->assertSame($discount->id, $result['wallet']['discount_id']);
$this->assertSame($discount->discount, $result['wallet']['discount']);
$this->assertSame($discount->description, $result['wallet']['discount_description']);
$this->assertSame($mod_provider, $result['wallet']['provider']);
$this->assertSame($discount->id, $result['wallets'][0]['discount_id']);
$this->assertSame($discount->discount, $result['wallets'][0]['discount']);
$this->assertSame($discount->description, $result['wallets'][0]['discount_description']);
$this->assertSame($mod_provider, $result['wallets'][0]['provider']);
$this->assertFalse($result['isLocked']);
// Jack is not a John's wallet controller
$jack = $this->getTestUser('jack@kolab.org');
$result = $this->invokeMethod(new UsersController(), 'userResponse', [$jack]);
$this->assertFalse($result['statusInfo']['enableDomains']);
$this->assertFalse($result['statusInfo']['enableWallets']);
$this->assertFalse($result['statusInfo']['enableWalletMandates']);
$this->assertFalse($result['statusInfo']['enableWalletPayments']);
$this->assertFalse($result['statusInfo']['enableUsers']);
$this->assertFalse($result['statusInfo']['enableSettings']);
$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']);
}
/**
* User email address validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmail(): void
{
Queue::fake();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setAliases(['folder-alias1@kolab.org']);
$folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder_del->setAliases(['folder-alias2@kolabnow.com']);
$folder_del->delete();
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$pub_group->delete();
$priv_group = $this->getTestGroup('group-test@kolab.org');
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->delete();
$cases = [
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
// Invalid format
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified email is invalid.'],
[".@$domain", $john, 'The specified email is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified email is not available.'],
["administrator@$domain", $john, 'The specified email is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user
["jack.daniels@kolab.org", $john, 'The specified email is not available.'],
// An existing shared folder or folder alias
["folder-event@kolab.org", $john, 'The specified email is not available.'],
["folder-alias1@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted shared folder or folder alias
["folder-test@kolabnow.com", $john, 'The specified email is not available.'],
["folder-alias2@kolabnow.com", $john, 'The specified email is not available.'],
// A group
["group-test@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted group
["group-test@kolabnow.com", $john, 'The specified email is not available.'],
// A resource
["resource-test1@kolab.org", $john, 'The specified email is not available.'],
// A soft-deleted resource
["resource-test@kolabnow.com", $john, 'The specified email is not available.'],
];
foreach ($cases as $idx => $case) {
list($email, $user, $expected) = $case;
$deleted = null;
$result = UsersController::validateEmail($email, $user, $deleted);
$this->assertSame($expected, $result, "Case {$email}");
$this->assertNull($deleted, "Case {$email}");
}
}
/**
* User email validation - tests for $deleted argument
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateEmailDeleted(): void
{
Queue::fake();
$john = $this->getTestUser('john@kolab.org');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->delete();
$result = UsersController::validateEmail('deleted@kolab.org', $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($deleted_priv->id, $deleted->id);
$result = UsersController::validateEmail('deleted@kolabnow.com', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$result = UsersController::validateEmail('jack@kolab.org', $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertSame(null, $deleted);
$pub_group = $this->getTestGroup('group-test@kolabnow.com');
$priv_group = $this->getTestGroup('group-test@kolab.org');
// A group in a public domain, existing
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$pub_group->delete();
// A group in a public domain, deleted
$result = UsersController::validateEmail($pub_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
// A group in a private domain, existing
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame('The specified email is not available.', $result);
$this->assertNull($deleted);
$priv_group->delete();
// A group in a private domain, deleted
$result = UsersController::validateEmail($priv_group->email, $john, $deleted);
$this->assertSame(null, $result);
$this->assertSame($priv_group->id, $deleted->id);
// TODO: Test the same with a resource and shared folder
}
/**
* User email alias validation.
*
* Note: Technically these include unit tests, but let's keep it here for now.
* FIXME: Shall we do a http request for each case?
*/
public function testValidateAlias(): void
{
Queue::fake();
$public_domains = Domain::getPublicDomains();
$domain = reset($public_domains);
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$deleted_priv = $this->getTestUser('deleted@kolab.org');
$deleted_priv->setAliases(['deleted-alias@kolab.org']);
$deleted_priv->delete();
$deleted_pub = $this->getTestUser('deleted@kolabnow.com');
$deleted_pub->setAliases(['deleted-alias@kolabnow.com']);
$deleted_pub->delete();
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setAliases(['folder-alias1@kolab.org']);
$folder_del = $this->getTestSharedFolder('folder-test@kolabnow.com');
$folder_del->setAliases(['folder-alias2@kolabnow.com']);
$folder_del->delete();
$group_priv = $this->getTestGroup('group-test@kolab.org');
$group = $this->getTestGroup('group-test@kolabnow.com');
$group->delete();
$resource = $this->getTestResource('resource-test@kolabnow.com');
$resource->delete();
$cases = [
// Invalid format
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
["test123456@localhost", $john, 'The specified domain is invalid.'],
["test123456@unknown-domain.org", $john, 'The specified domain is invalid.'],
["$domain", $john, 'The specified alias is invalid.'],
[".@$domain", $john, 'The specified alias is invalid.'],
// forbidden local part on public domains
["admin@$domain", $john, 'The specified alias is not available.'],
["administrator@$domain", $john, 'The specified alias is not available.'],
// forbidden (other user's domain)
["testtest@kolab.org", $user, 'The specified domain is not available.'],
// existing alias of other user, to be an alias, user in the same group account
["jack.daniels@kolab.org", $john, null],
// existing user
["jack@kolab.org", $john, 'The specified alias is not available.'],
// valid (user domain)
["admin@kolab.org", $john, null],
// valid (public domain)
["test.test@$domain", $john, null],
// An alias that was a user email before is allowed, but only for custom domains
["deleted@kolab.org", $john, null],
["deleted-alias@kolab.org", $john, null],
["deleted@kolabnow.com", $john, 'The specified alias is not available.'],
["deleted-alias@kolabnow.com", $john, 'The specified alias is not available.'],
// An existing shared folder or folder alias
["folder-event@kolab.org", $john, 'The specified alias is not available.'],
["folder-alias1@kolab.org", $john, null],
// A soft-deleted shared folder or folder alias
["folder-test@kolabnow.com", $john, 'The specified alias is not available.'],
["folder-alias2@kolabnow.com", $john, 'The specified alias is not available.'],
// A group with the same email address exists
["group-test@kolab.org", $john, 'The specified alias is not available.'],
// A soft-deleted group
["group-test@kolabnow.com", $john, 'The specified alias is not available.'],
// A resource
["resource-test1@kolab.org", $john, 'The specified alias is not available.'],
// A soft-deleted resource
["resource-test@kolabnow.com", $john, 'The specified alias is not available.'],
];
foreach ($cases as $idx => $case) {
list($alias, $user, $expected) = $case;
$result = UsersController::validateAlias($alias, $user);
$this->assertSame($expected, $result, "Case {$alias}");
}
}
}
diff --git a/src/tests/Unit/UtilsTest.php b/src/tests/Unit/UtilsTest.php
index 7286a7f3..e4debe70 100644
--- a/src/tests/Unit/UtilsTest.php
+++ b/src/tests/Unit/UtilsTest.php
@@ -1,194 +1,206 @@
<?php
namespace Tests\Unit;
use App\Utils;
use Tests\TestCase;
class UtilsTest extends TestCase
{
/**
* Test for Utils::countryForIP()
*/
public function testCountryForIP(): void
{
// Create some network records, the tables might be empty
\App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
\App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete();
$this->assertSame('', Utils::countryForIP('127.0.0.1', ''));
$this->assertSame('CH', Utils::countryForIP('127.0.0.1'));
$this->assertSame('', Utils::countryForIP('2001:db8::ff00:42:1', ''));
$this->assertSame('CH', Utils::countryForIP('2001:db8::ff00:42:1'));
\App\IP4Net::create([
'net_number' => '127.0.0.0',
'net_broadcast' => '127.255.255.255',
'net_mask' => 8,
'country' => 'US',
'rir_name' => 'test',
'serial' => 1,
]);
\App\IP6Net::create([
'net_number' => '2001:db8::ff00:42:0',
'net_broadcast' => \App\Utils::ip6Broadcast('2001:db8::ff00:42:0', 8),
'net_mask' => 8,
'country' => 'PL',
'rir_name' => 'test',
'serial' => 1,
]);
$this->assertSame('US', Utils::countryForIP('127.0.0.1', ''));
$this->assertSame('US', Utils::countryForIP('127.0.0.1'));
$this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1', ''));
$this->assertSame('PL', Utils::countryForIP('2001:db8::ff00:42:1'));
\App\IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
\App\IP6Net::where('net_number', inet_pton('2001:db8::ff00:42:0'))->delete();
}
/**
* Test for Utils::emailToLower()
*/
public function testEmailToLower(): void
{
$this->assertSame('test@test.tld', Utils::emailToLower('test@Test.Tld'));
$this->assertSame('test@test.tld', Utils::emailToLower('Test@Test.Tld'));
$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()
*/
public function testNormalizeAddress(): void
{
$this->assertSame('', Utils::normalizeAddress(''));
$this->assertSame('', Utils::normalizeAddress(null));
$this->assertSame('test', Utils::normalizeAddress('TEST'));
$this->assertSame('test@domain.tld', Utils::normalizeAddress('Test@Domain.TLD'));
$this->assertSame('test@domain.tld', Utils::normalizeAddress('Test+Trash@Domain.TLD'));
$this->assertSame(['', ''], Utils::normalizeAddress('', true));
$this->assertSame(['', ''], Utils::normalizeAddress(null, true));
$this->assertSame(['test', ''], Utils::normalizeAddress('TEST', true));
$this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test@Domain.TLD', true));
$this->assertSame(['test', 'domain.tld'], Utils::normalizeAddress('Test+Trash@Domain.TLD', true));
}
/**
* Test for Utils::powerSet()
*/
public function testPowerSet(): void
{
$set = [];
$result = Utils::powerSet($set);
$this->assertIsArray($result);
$this->assertCount(0, $result);
$set = ["a1"];
$result = Utils::powerSet($set);
$this->assertIsArray($result);
$this->assertCount(1, $result);
$this->assertTrue(in_array(["a1"], $result));
$set = ["a1", "a2"];
$result = Utils::powerSet($set);
$this->assertIsArray($result);
$this->assertCount(3, $result);
$this->assertTrue(in_array(["a1"], $result));
$this->assertTrue(in_array(["a2"], $result));
$this->assertTrue(in_array(["a1", "a2"], $result));
$set = ["a1", "a2", "a3"];
$result = Utils::powerSet($set);
$this->assertIsArray($result);
$this->assertCount(7, $result);
$this->assertTrue(in_array(["a1"], $result));
$this->assertTrue(in_array(["a2"], $result));
$this->assertTrue(in_array(["a3"], $result));
$this->assertTrue(in_array(["a1", "a2"], $result));
$this->assertTrue(in_array(["a1", "a3"], $result));
$this->assertTrue(in_array(["a2", "a3"], $result));
$this->assertTrue(in_array(["a1", "a2", "a3"], $result));
}
/**
* Test for Utils::serviceUrl()
*/
public function testServiceUrl(): void
{
$public_href = 'https://public.url/cockpit';
$local_href = 'https://local.url/cockpit';
\config([
'app.url' => $local_href,
'app.public_url' => '',
]);
$this->assertSame($local_href, Utils::serviceUrl(''));
$this->assertSame($local_href . '/unknown', Utils::serviceUrl('unknown'));
$this->assertSame($local_href . '/unknown', Utils::serviceUrl('/unknown'));
\config([
'app.url' => $local_href,
'app.public_url' => $public_href,
]);
$this->assertSame($public_href, Utils::serviceUrl(''));
$this->assertSame($public_href . '/unknown', Utils::serviceUrl('unknown'));
$this->assertSame($public_href . '/unknown', Utils::serviceUrl('/unknown'));
}
/**
* Test for Utils::uuidInt()
*/
public function testUuidInt(): void
{
$result = Utils::uuidInt();
$this->assertTrue(is_int($result));
$this->assertTrue($result > 0);
}
/**
* Test for Utils::uuidStr()
*/
public function testUuidStr(): void
{
$result = Utils::uuidStr();
$this->assertTrue(is_string($result));
$this->assertTrue(strlen($result) === 36);
$this->assertTrue(preg_match('/[^a-f0-9-]/i', $result) === 0);
}
/**
* Test for Utils::exchangeRate()
*/
public function testExchangeRate(): void
{
$this->assertSame(1.0, Utils::exchangeRate("DUMMY", "dummy"));
// Exchange rates are volatile, can't test with high accuracy.
$this->assertTrue(Utils::exchangeRate("CHF", "EUR") >= 0.88);
//$this->assertEqualsWithDelta(0.90503424978382, Utils::exchangeRate("CHF", "EUR"), PHP_FLOAT_EPSILON);
$this->assertTrue(Utils::exchangeRate("EUR", "CHF") <= 1.12);
//$this->assertEqualsWithDelta(1.1049305595217682, Utils::exchangeRate("EUR", "CHF"), PHP_FLOAT_EPSILON);
$this->expectException(\Exception::class);
$this->assertSame(1.0, Utils::exchangeRate("CHF", "FOO"));
$this->expectException(\Exception::class);
$this->assertSame(1.0, Utils::exchangeRate("FOO", "CHF"));
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sat, Jan 31, 4:41 PM (1 d, 3 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
426388
Default Alt Text
(498 KB)

Event Timeline