diff --git a/src/.env.example b/src/.env.example index 2c9d9e58..2811f18d 100644 --- a/src/.env.example +++ b/src/.env.example @@ -1,94 +1,98 @@ APP_NAME=Kolab APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com LOG_CHANNEL=stack DB_CONNECTION=mysql DB_DATABASE=kolabdev DB_HOST=127.0.0.1 DB_PASSWORD=kolab DB_PORT=3306 DB_USERNAME=kolabdev BROADCAST_DRIVER=log CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=file SESSION_LIFETIME=120 2FA_DSN=mysql://roundcube:Welcome2KolabSystems@127.0.0.1/roundcube 2FA_TOTP_DIGITS=6 2FA_TOTP_INTERVAL=30 2FA_TOTP_DIGEST=sha1 IMAP_URI=ssl://127.0.0.1:993 IMAP_ADMIN_LOGIN=cyrus-admin IMAP_ADMIN_PASSWORD=Welcome2KolabSystems IMAP_VERIFY_HOST=false IMAP_VERIFY_PEER=false LDAP_BASE_DN="dc=mgmt,dc=com" LDAP_DOMAIN_BASE_DN="ou=Domains,dc=mgmt,dc=com" LDAP_HOSTS=127.0.0.1 LDAP_PORT=389 LDAP_SERVICE_BIND_DN="uid=kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_SERVICE_BIND_PW="Welcome2KolabSystems" LDAP_USE_SSL=false LDAP_USE_TLS=false # Administrative LDAP_ADMIN_BIND_DN="cn=Directory Manager" LDAP_ADMIN_BIND_PW="Welcome2KolabSystems" LDAP_ADMIN_ROOT_DN="dc=mgmt,dc=com" # Hosted (public registration) LDAP_HOSTED_BIND_DN="uid=hosted-kolab-service,ou=Special Users,dc=mgmt,dc=com" LDAP_HOSTED_BIND_PW="Welcome2KolabSystems" LDAP_HOSTED_ROOT_DN="dc=hosted,dc=com" REDIS_HOST=127.0.0.1 REDIS_PASSWORD=null REDIS_PORT=6379 SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 +PAYMENT_PROVIDER= MOLLIE_KEY= +STRIPE_KEY= +STRIPE_PUBLIC_KEY= +STRIPE_WEBHOOK_SECRET= MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null MAIL_FROM_ADDRESS="noreply@example.com" MAIL_FROM_NAME="Example.com" MAIL_REPLYTO_ADDRESS=null MAIL_REPLYTO_NAME=null DNS_TTL=3600 DNS_SPF="v=spf1 mx -all" DNS_STATIC="%s. MX 10 ext-mx01.mykolab.com." DNS_COPY_FROM=null AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= PUSHER_APP_ID= PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER=mt1 MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}" MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" JWT_SECRET= diff --git a/src/app/Console/Commands/MollieInfo.php b/src/app/Console/Commands/MollieInfo.php new file mode 100644 index 00000000..4393f32c --- /dev/null +++ b/src/app/Console/Commands/MollieInfo.php @@ -0,0 +1,78 @@ +argument('user')) { + $user = User::where('email', $this->argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user: {$user->id}"); + + $wallet = $user->wallets->first(); + $provider = new \App\Providers\Payment\Mollie(); + + if ($mandate = $provider->getMandate($wallet)) { + $amount = $wallet->getSetting('mandate_amount'); + $balance = $wallet->getSetting('mandate_balance') ?: 0; + + $this->info("Auto-payment: {$mandate['method']}"); + $this->info(" id: {$mandate['id']}"); + $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid')); + $this->info(" amount: {$amount} {$wallet->currency}"); + $this->info(" min-balance: {$balance} {$wallet->currency}"); + } else { + $this->info("Auto-payment: none"); + } + + // TODO: List user payments history + } else { + $this->info("Available payment methods:"); + + foreach (mollie()->methods()->all() as $method) { + $this->info("- {$method->description} ({$method->id}):"); + $this->info(" status: {$method->status}"); + $this->info(sprintf( + " min: %s %s", + $method->minimumAmount->value, + $method->minimumAmount->currency + )); + if (!empty($method->maximumAmount)) { + $this->info(sprintf( + " max: %s %s", + $method->maximumAmount->value, + $method->maximumAmount->currency + )); + } + } + } + } +} diff --git a/src/app/Console/Commands/StripeInfo.php b/src/app/Console/Commands/StripeInfo.php new file mode 100644 index 00000000..46bc0525 --- /dev/null +++ b/src/app/Console/Commands/StripeInfo.php @@ -0,0 +1,63 @@ +argument('user')) { + $user = User::where('email', $this->argument('user'))->first(); + + if (!$user) { + return 1; + } + + $this->info("Found user: {$user->id}"); + + $wallet = $user->wallets->first(); + $provider = PaymentProvider::factory('stripe'); + + if ($mandate = $provider->getMandate($wallet)) { + $amount = $wallet->getSetting('mandate_amount'); + $balance = $wallet->getSetting('mandate_balance') ?: 0; + + $this->info("Auto-payment: {$mandate['method']}"); + $this->info(" id: {$mandate['id']}"); + $this->info(" status: " . ($mandate['isPending'] ? 'pending' : 'valid')); + $this->info(" amount: {$amount} {$wallet->currency}"); + $this->info(" min-balance: {$balance} {$wallet->currency}"); + } else { + $this->info("Auto-payment: none"); + } + + // TODO: List user payments history + } else { + // TODO: Fetch some info/stats from Stripe + } + } +} diff --git a/src/app/Http/Controllers/API/AuthController.php b/src/app/Http/Controllers/API/AuthController.php index bcb423cc..3b84e2b3 100644 --- a/src/app/Http/Controllers/API/AuthController.php +++ b/src/app/Http/Controllers/API/AuthController.php @@ -1,125 +1,127 @@ user(); $response = V4\UsersController::userResponse($user); return response()->json($response); } /** * Helper method for other controllers with user auto-logon * functionality * * @param \App\User $user User model object */ public static function logonResponse(User $user) { + // @phpstan-ignore-next-line $token = Auth::guard()->login($user); return response()->json([ 'status' => 'success', 'access_token' => $token, 'token_type' => 'bearer', // @phpstan-ignore-next-line 'expires_in' => Auth::guard()->factory()->getTTL() * 60, ]); } /** * Get a JWT token via given credentials. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse */ public function login(Request $request) { // TODO: Redirect to dashboard if authenticated. $v = Validator::make( $request->all(), [ 'email' => 'required|min:2', 'password' => 'required|min:4', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $credentials = $request->only('email', 'password'); if ($token = Auth::guard()->attempt($credentials)) { $sf = new \App\Auth\SecondFactor(Auth::guard()->user()); if ($response = $sf->requestHandler($request)) { return $response; } return $this->respondWithToken($token); } return response()->json(['status' => 'error', 'message' => __('auth.failed')], 401); } /** * Log the user out (Invalidate the token) * * @return \Illuminate\Http\JsonResponse */ public function logout() { Auth::guard()->logout(); return response()->json([ 'status' => 'success', 'message' => __('auth.logoutsuccess') ]); } /** * Refresh a token. * * @return \Illuminate\Http\JsonResponse */ public function refresh() { + // @phpstan-ignore-next-line return $this->respondWithToken(Auth::guard()->refresh()); } /** * Get the token array structure. * * @param string $token Respond with this token. * * @return \Illuminate\Http\JsonResponse */ protected function respondWithToken($token) { return response()->json( [ 'access_token' => $token, 'token_type' => 'bearer', // @phpstan-ignore-next-line 'expires_in' => Auth::guard()->factory()->getTTL() * 60 ] ); } } diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php index 7606364a..f042dab0 100644 --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -1,49 +1,86 @@ errorResponse(404); + } + + $result = $wallet->toArray(); + + $result['discount'] = 0; + $result['discount_description'] = ''; + + if ($wallet->discount) { + $result['discount'] = $wallet->discount->discount; + $result['discount_description'] = $wallet->discount->description; + } + + $result['mandate'] = PaymentsController::walletMandate($wallet); + + $provider = PaymentProvider::factory($wallet); + + $result['provider'] = $provider->name(); + $result['providerLink'] = $provider->customerLink($wallet); + + return $result; + } + /** * Update wallet data. * * @param \Illuminate\Http\Request $request The API request. * @params string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $wallet = Wallet::find($id); if (empty($wallet)) { return $this->errorResponse(404); } if (array_key_exists('discount', $request->input())) { if (empty($request->discount)) { $wallet->discount()->dissociate(); $wallet->save(); } elseif ($discount = Discount::find($request->discount)) { $wallet->discount()->associate($discount); $wallet->save(); } } $response = $wallet->toArray(); if ($wallet->discount) { $response['discount'] = $wallet->discount->discount; $response['discount_description'] = $wallet->discount->description; } $response['status'] = 'success'; $response['message'] = \trans('app.wallet-update-success'); return response()->json($response); } } diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index 069db528..916842c0 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,220 +1,275 @@ user(); + + // TODO: Wallet selection + $wallet = $user->wallets->first(); + + $mandate = self::walletMandate($wallet); + + return response()->json($mandate); + } + + /** + * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ - public function store(Request $request) + public function mandateCreate(Request $request) { $current_user = Auth::guard()->user(); // TODO: Wallet selection - $wallet = $current_user->wallets()->first(); + $wallet = $current_user->wallets->first(); + + $rules = [ + 'amount' => 'required|numeric', + 'balance' => 'required|numeric|min:0', + ]; // Check required fields - $v = Validator::make( - $request->all(), - [ - 'amount' => 'required|int|min:1', - ] - ); + $v = Validator::make($request->all(), $rules); + + // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Register the user in Mollie, if not yet done - // FIXME: Maybe Mollie ID should be bound to a wallet, but then - // The same customer could technicly have multiple - // Mollie IDs, then we'd need to use some "virtual" email - // address (e.g. @) instead of the user email address - $customer_id = $current_user->getSetting('mollie_id'); - $seq_type = 'oneoff'; - - if (empty($customer_id)) { - $customer = mollie()->customers()->create([ - 'name' => $current_user->name(), - 'email' => $current_user->email, - ]); - - $seq_type = 'first'; - $customer_id = $customer->id; - $current_user->setSetting('mollie_id', $customer_id); + $amount = (int) ($request->amount * 100); + + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); } - $payment_request = [ - 'amount' => [ - 'currency' => 'CHF', - // a number with two decimals is required - 'value' => sprintf('%.2f', $request->amount / 100), - ], - 'customerId' => $customer_id, - 'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring' - 'description' => 'Kolab Now Payment', // required - 'redirectUrl' => \url('/wallet'), // required for non-recurring payments - 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), - 'locale' => 'en_US', + $wallet->setSetting('mandate_amount', $request->amount); + $wallet->setSetting('mandate_balance', $request->balance); + + $request = [ + 'currency' => 'CHF', + 'amount' => $amount, + 'description' => \config('app.name') . ' Auto-Payment Setup', ]; - // Create the payment in Mollie - $payment = mollie()->payments()->create($payment_request); + $provider = PaymentProvider::factory($wallet); + + $result = $provider->createMandate($wallet, $request); + + $result['status'] = 'success'; + + return response()->json($result); + } + + /** + * Revoke the auto-payment mandate. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function mandateDelete() + { + $user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $user->wallets->first(); + + $provider = PaymentProvider::factory($wallet); - // Store the payment reference in database - self::storePayment($payment, $wallet->id, $request->amount); + $provider->deleteMandate($wallet); return response()->json([ 'status' => 'success', - 'redirectUrl' => $payment->getCheckoutUrl(), + 'message' => \trans('app.mandate-delete-success'), ]); } /** - * Update payment status (and balance). + * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * - * @return \Illuminate\Http\Response The response + * @return \Illuminate\Http\JsonResponse The response */ - public function webhook(Request $request) + public function mandateUpdate(Request $request) { - $db_payment = Payment::find($request->id); + $current_user = Auth::guard()->user(); - // Mollie recommends to return "200 OK" even if the payment does not exist - if (empty($db_payment)) { - return response('Success', 200); - } + // TODO: Wallet selection + $wallet = $current_user->wallets->first(); - // Get the payment details from Mollie - $payment = mollie()->payments()->get($request->id); + $rules = [ + 'amount' => 'required|numeric', + 'balance' => 'required|numeric|min:0', + ]; - if (empty($payment)) { - return response('Success', 200); - } + // Check required fields + $v = Validator::make($request->all(), $rules); - if ($payment->isPaid()) { - if (!$payment->hasRefunds() && !$payment->hasChargebacks()) { - // The payment is paid and isn't refunded or charged back. - // Update the balance, if it wasn't already - if ($db_payment->status != 'paid') { - $db_payment->wallet->credit($db_payment->amount); - } - } elseif ($payment->hasRefunds()) { - // The payment has been (partially) refunded. - // The status of the payment is still "paid" - // TODO: Update balance - } elseif ($payment->hasChargebacks()) { - // The payment has been (partially) charged back. - // The status of the payment is still "paid" - // TODO: Update balance - } + // TODO: allow comma as a decimal point? + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // This is a sanity check, just in case the payment provider api - // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid. - if ($db_payment->status != 'paid') { - $db_payment->status = $payment->status; - $db_payment->save(); + $amount = (int) ($request->amount * 100); + + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); } - return response('Success', 200); + $wallet->setSetting('mandate_amount', $request->amount); + $wallet->setSetting('mandate_balance', $request->balance); + + return response()->json([ + 'status' => 'success', + 'message' => \trans('app.mandate-update-success'), + ]); } /** - * Charge a wallet with a "recurring" payment. + * Create a new payment. * - * @param \App\Wallet $wallet The wallet to charge - * @param int $amount The amount of money in cents + * @param \Illuminate\Http\Request $request The API request. * - * @return bool + * @return \Illuminate\Http\JsonResponse The response */ - public static function directCharge(Wallet $wallet, $amount): bool + public function store(Request $request) { - $customer_id = $wallet->owner->getSetting('mollie_id'); + $current_user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $current_user->wallets->first(); + + $rules = [ + 'amount' => 'required|numeric', + ]; + + // Check required fields + $v = Validator::make($request->all(), $rules); + + // TODO: allow comma as a decimal point? - if (empty($customer_id)) { - return false; + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } - // Check if there's at least one valid mandate - $mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) { - return $mandate->isValid(); - }); + $amount = (int) ($request->amount * 100); - if (empty($mandates)) { - return false; + // Validate the minimum value + if ($amount < PaymentProvider::MIN_AMOUNT) { + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; + return response()->json(['status' => 'error', 'errors' => $errors], 422); } - $payment_request = [ - 'amount' => [ - 'currency' => 'CHF', - // a number with two decimals is required - 'value' => sprintf('%.2f', $amount / 100), - ], - 'customerId' => $customer_id, - 'sequenceType' => 'recurring', - 'description' => 'Kolab Now Recurring Payment', - 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), + $request = [ + 'type' => PaymentProvider::TYPE_ONEOFF, + 'currency' => 'CHF', + 'amount' => $amount, + 'description' => \config('app.name') . ' Payment', ]; - // Create the payment in Mollie - $payment = mollie()->payments()->create($payment_request); + $provider = PaymentProvider::factory($wallet); + + $result = $provider->payment($wallet, $request); + + $result['status'] = 'success'; - // Store the payment reference in database - self::storePayment($payment, $wallet->id, $amount); + return response()->json($result); + } + + /** + * Update payment status (and balance). + * + * @param string $provider Provider name + * + * @return \Illuminate\Http\Response The response + */ + public function webhook($provider) + { + $code = 200; - return true; + if ($provider = PaymentProvider::factory($provider)) { + $code = $provider->webhook(); + } + + return response($code < 400 ? 'Success' : 'Server error', $code); } /** - * Create self URL + * Charge a wallet with a "recurring" payment. * - * @param string $route Route/Path + * @param \App\Wallet $wallet The wallet to charge + * @param int $amount The amount of money in cents * - * @return string Full URL + * @return bool */ - protected static function serviceUrl(string $route): string + public static function directCharge(Wallet $wallet, $amount): bool { - $url = \url($route); + $request = [ + 'type' => PaymentProvider::TYPE_RECURRING, + 'currency' => 'CHF', + 'amount' => $amount, + 'description' => \config('app.name') . ' Recurring Payment', + ]; - $app_url = trim(\config('app.url'), '/'); - $pub_url = trim(\config('app.public_url'), '/'); + $provider = PaymentProvider::factory($wallet); - if ($pub_url != $app_url) { - $url = str_replace($app_url, $pub_url, $url); + if ($result = $provider->payment($wallet, $request)) { + return true; } - return $url; + return false; } /** - * Create a payment record in DB + * Returns auto-payment mandate info for the specified wallet + * + * @param \App\Wallet $wallet A wallet object * - * @param object $payment Mollie payment - * @param string $wallet_id Wallet ID - * @param int $amount Amount of money in cents + * @return array A mandate metadata */ - protected static function storePayment($payment, $wallet_id, $amount): void + public static function walletMandate(Wallet $wallet): array { - $db_payment = new Payment(); - $db_payment->id = $payment->id; - $db_payment->description = $payment->description; - $db_payment->status = $payment->status; - $db_payment->amount = $amount; - $db_payment->wallet_id = $wallet_id; - $db_payment->save(); + $provider = PaymentProvider::factory($wallet); + + // Get the Mandate info + $mandate = (array) $provider->getMandate($wallet); + + $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); + $mandate['balance'] = 0; + + foreach (['amount', 'balance'] as $key) { + if (($value = $wallet->getSetting("mandate_{$key}")) !== null) { + $mandate[$key] = $value; + } + } + + return $mandate; } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php index f6fabeab..a7c72dfe 100644 --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -1,599 +1,602 @@ errorResponse(404); } // User can't remove himself until he's the controller if (!$this->guard()->user()->canDelete($user)) { return $this->errorResponse(403); } $user->delete(); return response()->json([ 'status' => 'success', 'message' => __('app.user-delete-success'), ]); } /** * Listing of users. * * The user-entitlements billed to the current user wallet(s) * * @return \Illuminate\Http\JsonResponse */ public function index() { \Log::debug("Regular API"); $user = $this->guard()->user(); $result = $user->users()->orderBy('email')->get()->map(function ($user) { $data = $user->toArray(); $data = array_merge($data, self::userStatuses($user)); return $data; }); return response()->json($result); } /** * Display information on the user account specified by $id. * * @param int $id The account to show information for. * * @return \Illuminate\Http\JsonResponse */ public function show($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = $this->userResponse($user); // Simplified Entitlement/SKU information, // TODO: I agree this format may need to be extended in future $response['skus'] = []; foreach ($user->entitlements as $ent) { $sku = $ent->sku; $response['skus'][$sku->id] = [ // 'cost' => $ent->cost, 'count' => isset($response['skus'][$sku->id]) ? $response['skus'][$sku->id]['count'] + 1 : 1, ]; } return response()->json($response); } /** * Fetch user status (and reload setup process) * * @param int $id User identifier * * @return \Illuminate\Http\JsonResponse */ public function status($id) { $user = User::find($id); if (empty($user)) { return $this->errorResponse(404); } if (!$this->guard()->user()->canRead($user)) { return $this->errorResponse(403); } $response = self::statusInfo($user); if (!empty(request()->input('refresh'))) { $updated = false; $last_step = 'none'; foreach ($response['process'] as $idx => $step) { $last_step = $step['label']; if (!$step['state']) { if (!$this->execProcessStep($user, $step['label'])) { break; } $updated = true; } } if ($updated) { $response = self::statusInfo($user); } $success = $response['isReady']; $suffix = $success ? 'success' : 'error-' . $last_step; $response['status'] = $success ? 'success' : 'error'; $response['message'] = \trans('app.process-' . $suffix); } $response = array_merge($response, self::userStatuses($user)); return response()->json($response); } /** * User status (extended) information * * @param \App\User $user User object * * @return array Status information */ public static function statusInfo(User $user): array { $process = []; $steps = [ 'user-new' => true, 'user-ldap-ready' => $user->isLdapReady(), 'user-imap-ready' => $user->isImapReady(), ]; // Create a process check list foreach ($steps as $step_name => $state) { $step = [ 'label' => $step_name, 'title' => \trans("app.process-{$step_name}"), 'state' => $state, ]; $process[] = $step; } list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); // If that is not a public domain, add domain specific steps if ($domain && !$domain->isPublic()) { $domain_status = DomainsController::statusInfo($domain); $process = array_merge($process, $domain_status['process']); } $all = count($process); $checked = count(array_filter($process, function ($v) { return $v['state']; })); $state = $all === $checked ? 'done' : 'running'; // After 180 seconds assume the process is in failed state, // this should unlock the Refresh button in the UI if ($all !== $checked && $user->created_at->diffInSeconds(Carbon::now()) > 180) { $state = 'failed'; } return [ 'process' => $process, 'processState' => $state, 'isReady' => $all === $checked, ]; } /** * 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->wallet()->owner; if ($owner->id != $current_user->id) { return $this->errorResponse(403); } if ($error_response = $this->validateUserRequest($request, null, $settings)) { return $error_response; } if (empty($request->package) || !($package = \App\Package::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(); // Create user record $user = User::create([ 'email' => $request->email, 'password' => $request->password, ]); $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' => __('app.user-create-success'), ]); } /** * Update user data. * * @param \Illuminate\Http\Request $request The API request. * @params string $id User identifier * * @return \Illuminate\Http\JsonResponse The response */ public function update(Request $request, $id) { $user = User::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(); $this->updateEntitlements($user, $request->skus); if (!empty($settings)) { $user->setSettings($settings); } if (!empty($request->password)) { $user->password = $request->password; $user->save(); } if (isset($request->aliases)) { $user->setAliases($request->aliases); } // TODO: Make sure that UserUpdate job is created in case of entitlements update // and no password change. So, for example quota change is applied to LDAP // TODO: Review use of $user->save() in the above context DB::commit(); return response()->json([ 'status' => 'success', 'message' => __('app.user-update-success'), ]); } /** * Get the guard to be used during authentication. * * @return \Illuminate\Contracts\Auth\Guard */ public function guard() { return Auth::guard(); } /** * Update user entitlements. * * @param \App\User $user The user * @param array|null $skus Set of SKUs for the user */ protected function updateEntitlements(User $user, $skus) { if (!is_array($skus)) { return; } // Existing SKUs // FIXME: Is there really no query builder method to get result indexed // by some column or primary key? $all_skus = Sku::all()->mapWithKeys(function ($sku) { return [$sku->id => $sku]; }); // Existing user entitlements // Note: We sort them by cost, so e.g. for storage we get these free first $entitlements = $user->entitlements()->orderBy('cost')->get(); // Go through existing entitlements and remove those no longer needed foreach ($entitlements as $ent) { $sku_id = $ent->sku_id; if (array_key_exists($sku_id, $skus)) { // An existing entitlement exists on the requested list $skus[$sku_id] -= 1; if ($skus[$sku_id] < 0) { $ent->delete(); } } elseif ($all_skus->get($sku_id)->handler_class != \App\Handlers\Mailbox::class) { // An existing entitlement does not exists on the requested list // Never delete 'mailbox' SKU $ent->delete(); } } // Add missing entitlements foreach ($skus as $sku_id => $count) { if ($count > 0 && $all_skus->has($sku_id)) { $user->assignSku($all_skus[$sku_id], $count); } } } /** * 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 = $user->toArray(); // Settings $response['settings'] = []; foreach ($user->settings()->whereIn('key', self::USER_SETTINGS)->get() as $item) { $response['settings'][$item->key] = $item->value; } // Aliases $response['aliases'] = []; foreach ($user->aliases as $item) { $response['aliases'][] = $item->alias; } // Status info $response['statusInfo'] = self::statusInfo($user); $response = array_merge($response, self::userStatuses($user)); - // Add discount info to wallet object output + // Add more info to the wallet object output $map_func = function ($wallet) use ($user) { $result = $wallet->toArray(); 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($user->wallet()); return $response; } /** * Prepare user statuses for the UI * * @param \App\User $user User object * * @return array Statuses array */ protected static function userStatuses(User $user): array { return [ 'isImapReady' => $user->isImapReady(), 'isLdapReady' => $user->isLdapReady(), 'isSuspended' => $user->isSuspended(), 'isActive' => $user->isActive(), 'isDeleted' => $user->isDeleted() || $user->trashed(), ]; } /** * 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', ]; if (empty($user) || !empty($request->password) || !empty($request->password_confirmation)) { $rules['password'] = 'required|min:4|max:2048|confirmed'; } $errors = []; // Validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { $errors = $v->errors()->toArray(); } $controller = $user ? $user->wallet()->owner : $this->guard()->user(); // For new user validate email address if (empty($user)) { $email = $request->email; if (empty($email)) { $errors['email'] = \trans('validation.required', ['attribute' => 'email']); } elseif ($error = \App\Utils::validateEmail($email, $controller, false)) { $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 = \App\Utils::validateEmail($alias, $controller, true)) ) { 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 True if the execution succeeded, False otherwise */ public static function execProcessStep(User $user, string $step): bool { try { if (strpos($step, 'domain-') === 0) { list ($local, $domain) = explode('@', $user->email); $domain = Domain::where('namespace', $domain)->first(); return DomainsController::execProcessStep($domain, $step); } switch ($step) { case 'user-ldap-ready': // User not in LDAP, create it $job = new \App\Jobs\UserCreate($user); $job->handle(); return $user->isLdapReady(); case 'user-imap-ready': // User not in IMAP? Verify again $job = new \App\Jobs\UserVerify($user); $job->handle(); return $user->isImapReady(); } } catch (\Exception $e) { \Log::error($e); } return false; } } diff --git a/src/app/Http/Kernel.php b/src/app/Http/Kernel.php index 1ccbd82b..16063a40 100644 --- a/src/app/Http/Kernel.php +++ b/src/app/Http/Kernel.php @@ -1,84 +1,85 @@ [ \App\Http\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ 'throttle:60,1', 'bindings', ], ]; /** * The application's route middleware. * * These middleware may be assigned to groups or used individually. * * @var array */ protected $routeMiddleware = [ 'admin' => \App\Http\Middleware\AuthenticateAdmin::class, 'auth' => \App\Http\Middleware\Authenticate::class, 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class, 'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class, 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class, 'can' => \Illuminate\Auth\Middleware\Authorize::class, 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'signed' => \Illuminate\Routing\Middleware\ValidateSignature::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, ]; /** * The priority-sorted list of middleware. * * This forces non-global middleware to always be in the given order. * * @var array */ protected $middlewarePriority = [ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\AuthenticateAdmin::class, \App\Http\Middleware\Authenticate::class, \Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Auth\Middleware\Authorize::class, \App\Http\Middleware\AuthenticateAdmin::class, ]; } diff --git a/src/app/Http/Middleware/DevelConfig.php b/src/app/Http/Middleware/DevelConfig.php new file mode 100644 index 00000000..9c2e2ef8 --- /dev/null +++ b/src/app/Http/Middleware/DevelConfig.php @@ -0,0 +1,38 @@ +getMethod() == 'GET' && isset($request->paymentProvider)) { + $provider = $request->paymentProvider; + } else { + $provider = $request->headers->get('X-TEST-PAYMENT-PROVIDER'); + } + + if (!empty($provider)) { + \config(['services.payment_provider' => $provider]); + } + } + + return $next($request); + } +} diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php new file mode 100644 index 00000000..4c82c8f7 --- /dev/null +++ b/src/app/Providers/Payment/Mollie.php @@ -0,0 +1,349 @@ + tag + */ + public function customerLink(Wallet $wallet): ?string + { + $customer_id = self::mollieCustomerId($wallet); + + return sprintf( + '%s', + $customer_id, + $customer_id + ); + } + + /** + * Create a new auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - description: Operation desc. + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + public function createMandate(Wallet $wallet, array $payment): ?array + { + // Register the user in Mollie, if not yet done + $customer_id = self::mollieCustomerId($wallet); + + $request = [ + 'amount' => [ + 'currency' => $payment['currency'], + 'value' => '0.00', + ], + 'customerId' => $customer_id, + 'sequenceType' => 'first', + 'description' => $payment['description'], + 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), + 'redirectUrl' => \url('/wallet'), + 'locale' => 'en_US', + // 'method' => 'creditcard', + ]; + + // Create the payment in Mollie + $response = mollie()->payments()->create($request); + + if ($response->mandateId) { + $wallet->setSetting('mollie_mandate_id', $response->mandateId); + } + + return [ + 'id' => $response->id, + 'redirectUrl' => $response->getCheckoutUrl(), + ]; + } + + /** + * Revoke the auto-payment mandate for the wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return bool True on success, False on failure + */ + public function deleteMandate(Wallet $wallet): bool + { + // Get the Mandate info + $mandate = self::mollieMandate($wallet); + + // Revoke the mandate on Mollie + if ($mandate) { + $mandate->revoke(); + + $wallet->setSetting('mollie_mandate_id', null); + } + + return true; + } + + /** + * Get a auto-payment mandate for the wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return array|null Mandate information: + * - id: Mandate identifier + * - method: user-friendly payment method desc. + * - isPending: the process didn't complete yet + * - isValid: the mandate is valid + */ + public function getMandate(Wallet $wallet): ?array + { + // Get the Mandate info + $mandate = self::mollieMandate($wallet); + + if (empty($mandate)) { + return null; + } + + $result = [ + 'id' => $mandate->id, + 'isPending' => $mandate->isPending(), + 'isValid' => $mandate->isValid(), + ]; + + $details = $mandate->details; + + // Mollie supports 3 methods here + switch ($mandate->method) { + case 'creditcard': + // If the customer started, but never finished the 'first' payment + // card details will be empty, and mandate will be 'pending'. + if (empty($details->cardNumber)) { + $result['method'] = 'Credit Card'; + } else { + $result['method'] = sprintf( + '%s (**** **** **** %s)', + $details->cardLabel ?: 'Card', // @phpstan-ignore-line + $details->cardNumber + ); + } + break; + + case 'directdebit': + $result['method'] = sprintf( + 'Direct Debit (%s)', + $details->customerAccount + ); + break; + + case 'paypal': + $result['method'] = sprintf('PayPal (%s)', $details->consumerAccount); + break; + + + default: + $result['method'] = 'Unknown method'; + } + + return $result; + } + + /** + * Get a provider name + * + * @return string Provider name + */ + public function name(): string + { + return 'mollie'; + } + + /** + * Create a new payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - type: oneoff/recurring + * - description: Operation desc. + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + public function payment(Wallet $wallet, array $payment): ?array + { + // Register the user in Mollie, if not yet done + $customer_id = self::mollieCustomerId($wallet); + + // Note: Required fields: description, amount/currency, amount/value + + $request = [ + 'amount' => [ + 'currency' => $payment['currency'], + // a number with two decimals is required + 'value' => sprintf('%.2f', $payment['amount'] / 100), + ], + 'customerId' => $customer_id, + 'sequenceType' => $payment['type'], + 'description' => $payment['description'], + 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), + 'locale' => 'en_US', + // 'method' => 'creditcard', + ]; + + if ($payment['type'] == self::TYPE_RECURRING) { + // Check if there's a valid mandate + $mandate = self::mollieMandate($wallet); + + if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { + return null; + } + + $request['mandateId'] = $mandate->id; + } else { + // required for non-recurring payments + $request['redirectUrl'] = \url('/wallet'); + + // TODO: Additional payment parameters for better fraud protection: + // billingEmail - for bank transfers, Przelewy24, but not creditcard + // billingAddress (it is a structured field not just text) + } + + // Create the payment in Mollie + $response = mollie()->payments()->create($request); + + // Store the payment reference in database + $payment['status'] = $response->status; + $payment['id'] = $response->id; + + self::storePayment($payment, $wallet->id); + + return [ + 'id' => $payment['id'], + 'redirectUrl' => $response->getCheckoutUrl(), + ]; + } + + /** + * Update payment status (and balance). + * + * @return int HTTP response code + */ + public function webhook(): int + { + $payment_id = \request()->input('id'); + + if (empty($payment_id)) { + return 200; + } + + $payment = Payment::find($payment_id); + + if (empty($payment)) { + // Mollie recommends to return "200 OK" even if the payment does not exist + return 200; + } + + // Get the payment details from Mollie + $mollie_payment = mollie()->payments()->get($payment_id); + + if (empty($mollie_payment)) { + // Mollie recommends to return "200 OK" even if the payment does not exist + return 200; + } + + if ($mollie_payment->isPaid()) { + if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) { + // The payment is paid and isn't refunded or charged back. + // Update the balance, if it wasn't already + if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { + $payment->wallet->credit($payment->amount); + } + } elseif ($mollie_payment->hasRefunds()) { + // The payment has been (partially) refunded. + // The status of the payment is still "paid" + // TODO: Update balance + } elseif ($mollie_payment->hasChargebacks()) { + // The payment has been (partially) charged back. + // The status of the payment is still "paid" + // TODO: Update balance + } + } elseif ($mollie_payment->isFailed()) { + // Note: I didn't find a way to get any description of the problem with a payment + \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); + } + + // This is a sanity check, just in case the payment provider api + // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid. + if ($payment->status != self::STATUS_PAID) { + $payment->status = $mollie_payment->status; + $payment->save(); + } + + return 200; + } + + /** + * Get Mollie customer identifier for specified wallet. + * Create one if does not exist yet. + * + * @param \App\Wallet $wallet The wallet + * + * @return string Mollie customer identifier + */ + protected static function mollieCustomerId(Wallet $wallet): string + { + $customer_id = $wallet->getSetting('mollie_id'); + + // Register the user in Mollie + if (empty($customer_id)) { + $customer = mollie()->customers()->create([ + 'name' => $wallet->owner->name(), + 'email' => $wallet->id . '@private.' . \config('app.domain'), + ]); + + $customer_id = $customer->id; + + $wallet->setSetting('mollie_id', $customer->id); + } + + return $customer_id; + } + + /** + * Get the active Mollie auto-payment mandate + */ + protected static function mollieMandate(Wallet $wallet) + { + $customer_id = $wallet->getSetting('mollie_id'); + $mandate_id = $wallet->getSetting('mollie_mandate_id'); + + // Get the manadate reference we already have + if ($customer_id && $mandate_id) { + $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); + if ($mandate) {// && ($mandate->isValid() || $mandate->isPending())) { + return $mandate; + } + } + + // Get all mandates from Mollie and find the active one + /* + foreach ($customer->mandates() as $mandate) { + if ($mandate->isValid() || $mandate->isPending()) { + $wallet->setSetting('mollie_mandate_id', $mandate->id); + return $mandate; + } + } + */ + } +} diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php new file mode 100644 index 00000000..83f42e7c --- /dev/null +++ b/src/app/Providers/Payment/Stripe.php @@ -0,0 +1,381 @@ + tag + */ + public function customerLink(Wallet $wallet): ?string + { + $customer_id = self::stripeCustomerId($wallet); + + $location = 'https://dashboard.stripe.com'; + + $key = \config('services.stripe.key'); + + if (strpos($key, 'sk_test_') === 0) { + $location .= '/test'; + } + + return sprintf( + '%s', + $location, + $customer_id, + $customer_id + ); + } + + /** + * Create a new auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - description: Operation desc. + * + * @return array Provider payment/session data: + * - id: Session identifier + */ + public function createMandate(Wallet $wallet, array $payment): ?array + { + // Register the user in Stripe, if not yet done + $customer_id = self::stripeCustomerId($wallet); + + $request = [ + 'customer' => $customer_id, + 'cancel_url' => \url('/wallet'), // required + 'success_url' => \url('/wallet'), // required + 'payment_method_types' => ['card'], // required + 'locale' => 'en', + 'mode' => 'setup', + ]; + + $session = StripeAPI\Checkout\Session::create($request); + + return [ + 'id' => $session->id, + ]; + } + + /** + * Revoke the auto-payment mandate. + * + * @param \App\Wallet $wallet The wallet + * + * @return bool True on success, False on failure + */ + public function deleteMandate(Wallet $wallet): bool + { + // Get the Mandate info + $mandate = self::stripeMandate($wallet); + + if ($mandate) { + // Remove the reference + $wallet->setSetting('stripe_mandate_id', null); + + // Detach the payment method on Stripe + $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); + $pm->detach(); + } + + return true; + } + + /** + * Get a auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return array|null Mandate information: + * - id: Mandate identifier + * - method: user-friendly payment method desc. + * - isPending: the process didn't complete yet + * - isValid: the mandate is valid + */ + public function getMandate(Wallet $wallet): ?array + { + // Get the Mandate info + $mandate = self::stripeMandate($wallet); + + if (empty($mandate)) { + return null; + } + + $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); + + $result = [ + 'id' => $mandate->id, + 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', + 'isValid' => $mandate->status == 'succeeded', + ]; + + switch ($pm->type) { + case 'card': + // TODO: card number + $result['method'] = \sprintf( + '%s (**** **** **** %s)', + // @phpstan-ignore-next-line + \ucfirst($pm->card->brand) ?: 'Card', + // @phpstan-ignore-next-line + $pm->card->last4 + ); + + break; + + default: + $result['method'] = 'Unknown method'; + } + + return $result; + } + + /** + * Get a provider name + * + * @return string Provider name + */ + public function name(): string + { + return 'stripe'; + } + + /** + * Create a new payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - type: first/oneoff/recurring + * - description: Operation desc. + * + * @return array Provider payment/session data: + * - id: Session identifier + */ + public function payment(Wallet $wallet, array $payment): ?array + { + if ($payment['type'] == self::TYPE_RECURRING) { + return $this->paymentRecurring($wallet, $payment); + } + + // Register the user in Stripe, if not yet done + $customer_id = self::stripeCustomerId($wallet); + + $request = [ + 'customer' => $customer_id, + 'cancel_url' => \url('/wallet'), // required + 'success_url' => \url('/wallet'), // required + 'payment_method_types' => ['card'], // required + 'locale' => 'en', + 'line_items' => [ + [ + 'name' => $payment['description'], + 'amount' => $payment['amount'], + 'currency' => \strtolower($payment['currency']), + 'quantity' => 1, + ] + ] + ]; + + $session = StripeAPI\Checkout\Session::create($request); + + // Store the payment reference in database + $payment['status'] = self::STATUS_OPEN; + $payment['id'] = $session->payment_intent; + + self::storePayment($payment, $wallet->id); + + return [ + 'id' => $session->id, + ]; + } + + /** + * Create a new automatic payment operation. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data (see self::payment()) + * + * @return array Provider payment/session data: + * - id: Session identifier + */ + protected function paymentRecurring(Wallet $wallet, array $payment): ?array + { + // Check if there's a valid mandate + $mandate = self::stripeMandate($wallet); + + if (empty($mandate)) { + return null; + } + + $request = [ + 'amount' => $payment['amount'], + 'currency' => \strtolower($payment['currency']), + 'description' => $payment['description'], + 'locale' => 'en', + 'off_session' => true, + 'receipt_email' => $wallet->owner->email, + 'customer' => $mandate->customer, + 'payment_method' => $mandate->payment_method, + ]; + + $intent = StripeAPI\PaymentIntent::create($request); + + // Store the payment reference in database + $payment['status'] = self::STATUS_OPEN; + $payment['id'] = $intent->id; + + self::storePayment($payment, $wallet->id); + + return [ + 'id' => $payment['id'], + ]; + } + + /** + * Update payment status (and balance). + * + * @return int HTTP response code + */ + public function webhook(): int + { + $payload = file_get_contents('php://input'); + $sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE']; + + // Parse and validate the input + try { + $event = StripeAPI\Webhook::constructEvent( + $payload, + $sig_header, + \config('services.stripe.webhook_secret') + ); + } catch (\UnexpectedValueException $e) { + // Invalid payload + return 400; + } + + switch ($event->type) { + case StripeAPI\Event::PAYMENT_INTENT_CANCELED: + case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED: + case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED: + $intent = $event->data->object; // @phpstan-ignore-line + $payment = Payment::find($intent->id); + + switch ($intent->status) { + case StripeAPI\PaymentIntent::STATUS_CANCELED: + $status = self::STATUS_CANCELED; + break; + case StripeAPI\PaymentIntent::STATUS_SUCCEEDED: + $status = self::STATUS_PAID; + break; + default: + $status = self::STATUS_PENDING; + } + + if ($status == self::STATUS_PAID) { + // Update the balance, if it wasn't already + if ($payment->status != self::STATUS_PAID) { + $payment->wallet->credit($payment->amount); + } + } elseif (!empty($intent->last_payment_error)) { + // See https://stripe.com/docs/error-codes for more info + \Log::info(sprintf( + 'Stripe payment failed (%s): %s', + $payment->id, + json_encode($intent->last_payment_error) + )); + } + + if ($payment->status != self::STATUS_PAID) { + $payment->status = $status; + $payment->save(); + } + + break; + + case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: + $intent = $event->data->object; // @phpstan-ignore-line + + // Find the wallet + // TODO: This query is potentially slow, we should find another way + // Maybe use payment/transactions table to store the reference + $setting = WalletSetting::where('key', 'stripe_id') + ->where('value', $intent->customer)->first(); + + if ($setting) { + $setting->wallet->setSetting('stripe_mandate_id', $intent->id); + } + + break; + } + + return 200; + } + + /** + * Get Stripe customer identifier for specified wallet. + * Create one if does not exist yet. + * + * @param \App\Wallet $wallet The wallet + * + * @return string Stripe customer identifier + */ + protected static function stripeCustomerId(Wallet $wallet): string + { + $customer_id = $wallet->getSetting('stripe_id'); + + // Register the user in Stripe + if (empty($customer_id)) { + $customer = StripeAPI\Customer::create([ + 'name' => $wallet->owner->name(), + // Stripe will display the email on Checkout page, editable, + // and use it to send the receipt (?), use the user email here + // 'email' => $wallet->id . '@private.' . \config('app.domain'), + 'email' => $wallet->owner->email, + ]); + + $customer_id = $customer->id; + + $wallet->setSetting('stripe_id', $customer->id); + } + + return $customer_id; + } + + /** + * Get the active Stripe auto-payment mandate (Setup Intent) + */ + protected static function stripeMandate(Wallet $wallet) + { + // Note: Stripe also has 'Mandate' objects, but we do not use these + + if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { + $mandate = StripeAPI\SetupIntent::retrieve($mandate_id); + // @phpstan-ignore-next-line + if ($mandate && $mandate->status != 'canceled') { + return $mandate; + } + } + } +} diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php new file mode 100644 index 00000000..e9c2112d --- /dev/null +++ b/src/app/Providers/PaymentProvider.php @@ -0,0 +1,149 @@ +getSetting('stripe_id')) { + $provider = 'stripe'; + } elseif ($provider_or_wallet->getSetting('mollie_id')) { + $provider = 'mollie'; + } + } else { + $provider = $provider_or_wallet; + } + + if (empty($provider)) { + $provider = \config('services.payment_provider') ?: 'mollie'; + } + + switch (\strtolower($provider)) { + case 'stripe': + return new \App\Providers\Payment\Stripe(); + + case 'mollie': + return new \App\Providers\Payment\Mollie(); + + default: + throw new \Exception("Invalid payment provider: {$provider}"); + } + } + + /** + * Create a new auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - description: Operation desc. + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + abstract public function createMandate(Wallet $wallet, array $payment): ?array; + + /** + * Revoke the auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return bool True on success, False on failure + */ + abstract public function deleteMandate(Wallet $wallet): bool; + + /** + * Get a auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return array|null Mandate information: + * - id: Mandate identifier + * - method: user-friendly payment method desc. + * - isPending: the process didn't complete yet + * - isValid: the mandate is valid + */ + abstract public function getMandate(Wallet $wallet): ?array; + + /** + * Get a link to the customer in the provider's control panel + * + * @param \App\Wallet $wallet The wallet + * + * @return string|null The string representing tag + */ + abstract public function customerLink(Wallet $wallet): ?string; + + /** + * Get a provider name + * + * @return string Provider name + */ + abstract public function name(): string; + + /** + * Create a new payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - type: first/oneoff/recurring + * - description: Operation description + * + * @return array Provider payment/session data: + * - id: Operation identifier + * - redirectUrl + */ + abstract public function payment(Wallet $wallet, array $payment): ?array; + + /** + * Update payment status (and balance). + * + * @return int HTTP response code + */ + abstract public function webhook(): int; + + /** + * Create a payment record in DB + * + * @param array $payment Payment information + * @param string $wallet_id Wallet ID + */ + protected static function storePayment(array $payment, $wallet_id): void + { + $db_payment = new Payment(); + $db_payment->id = $payment['id']; + $db_payment->description = $payment['description']; + $db_payment->status = $payment['status']; + $db_payment->amount = $payment['amount']; + $db_payment->wallet_id = $wallet_id; + $db_payment->save(); + } +} diff --git a/src/app/Traits/UserSettingsTrait.php b/src/app/Traits/SettingsTrait.php similarity index 63% rename from src/app/Traits/UserSettingsTrait.php rename to src/app/Traits/SettingsTrait.php index d5091236..9986aaa6 100644 --- a/src/app/Traits/UserSettingsTrait.php +++ b/src/app/Traits/SettingsTrait.php @@ -1,121 +1,144 @@ 'some@other.erg']); * $locale = $user->getSetting('locale'); * ``` * - * @param string $key Lookup key + * @param string $key Setting name * - * @return string|null + * @return string|null Setting value */ public function getSetting(string $key) { $settings = $this->getCache(); if (!array_key_exists($key, $settings)) { return null; } $value = $settings[$key]; return empty($value) ? null : $value; } + /** + * Remove a setting. + * + * Example Usage: + * + * ```php + * $user = User::firstOrCreate(['email' => 'some@other.erg']); + * $user->removeSetting('locale'); + * ``` + * + * @param string $key Setting name + * + * @return void + */ + public function removeSetting(string $key): void + { + $this->setSetting($key, null); + } + /** * Create or update a setting. * * Example Usage: * * ```php * $user = User::firstOrCreate(['email' => 'some@other.erg']); * $user->setSetting('locale', 'en'); * ``` * * @param string $key Setting name * @param string|null $value The new value for the setting. * * @return void */ - public function setSetting(string $key, $value) + public function setSetting(string $key, $value): void { $this->storeSetting($key, $value); $this->setCache(); } /** * Create or update multiple settings in one fell swoop. * * Example Usage: * * ```php * $user = User::firstOrCreate(['email' => 'some@other.erg']); * $user->setSettings(['locale', 'en', 'country' => 'GB']); * ``` * * @param array $data An associative array of key value pairs. * * @return void */ - public function setSettings(array $data = []) + public function setSettings(array $data = []): void { foreach ($data as $key => $value) { $this->storeSetting($key, $value); } $this->setCache(); } private function storeSetting(string $key, $value): void { if ($value === null || $value === '') { - if ($setting = UserSetting::where(['user_id' => $this->id, 'key' => $key])->first()) { + // Note: We're selecting the record first, so observers can act + if ($setting = $this->settings()->where('key', $key)->first()) { $setting->delete(); } } else { - UserSetting::updateOrCreate( - ['user_id' => $this->id, 'key' => $key], + $this->settings()->updateOrCreate( + ['key' => $key], ['value' => $value] ); } } private function getCache() { - if (Cache::has('user_settings_' . $this->id)) { - return Cache::get('user_settings_' . $this->id); + $model = \strtolower(get_class($this)); + + if (Cache::has("{$model}_settings_{$this->id}")) { + return Cache::get("{$model}_settings_{$this->id}"); } return $this->setCache(); } private function setCache() { - if (Cache::has('user_settings_' . $this->id)) { - Cache::forget('user_settings_' . $this->id); + $model = \strtolower(get_class($this)); + + if (Cache::has("{$model}_settings_{$this->id}")) { + Cache::forget("{$model}_settings_{$this->id}"); } $cached = []; foreach ($this->settings()->get() as $entry) { if ($entry->value !== null && $entry->value !== '') { $cached[$entry->key] = $entry->value; } } - Cache::forever('user_settings_' . $this->id, $cached); + Cache::forever("{$model}_settings_{$this->id}", $cached); return $this->getCache(); } } diff --git a/src/app/User.php b/src/app/User.php index 093a0931..2fcea229 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,620 +1,620 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku($sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); // TODO: Sanity check, this probably should be in preReq() on handlers // or in EntitlementObserver if ($sku->handler_class::entitleableClass() != User::class) { throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); } while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $sku->units_free >= $exists ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($this->role == "admin") { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } $wallet = $object->wallet(); return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can update data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } return $this->canDelete($object); } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * @param bool $external Search also by an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $alias = UserAlias::where('alias', $email)->first(); if ($alias) { return $alias->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', 'App\User'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { $entitlement = $this->entitlement()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index 3eb059f2..85fd89a7 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,164 +1,188 @@ toString(); } 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 a configuration/environment data to be passed to * the UI * * @todo For a lack of better place this is put here for now * * @return array Configuration data */ public static function uiEnv(): array { $opts = ['app.name', 'app.url', 'app.domain']; $env = \app('config')->getMany($opts); $countries = include resource_path('countries.php'); $env['countries'] = $countries ?: []; $isAdmin = strpos(request()->getHttpHost(), 'admin.') === 0; $env['jsapp'] = $isAdmin ? 'admin.js' : 'user.js'; + $env['paymentProvider'] = \config('services.payment_provider'); + $env['stripePK'] = \config('services.stripe.public_key'); + return $env; } + /** + * Create self URL + * + * @param string $route Route/Path + * + * @return string Full URL + */ + public static function serviceUrl(string $route): string + { + $url = \url($route); + + $app_url = trim(\config('app.url'), '/'); + $pub_url = trim(\config('app.public_url'), '/'); + + if ($pub_url != $app_url) { + $url = str_replace($app_url, $pub_url, $url); + } + + return $url; + } + /** * Email address (login or alias) validation * * @param string $email Email address * @param \App\User $user The account owner * @param bool $is_alias The email is an alias * * @return string Error message on validation error */ public static function validateEmail( string $email, \App\User $user, bool $is_alias = false ): ?string { $attribute = $is_alias ? 'alias' : 'email'; if (strpos($email, '@') === false) { return \trans('validation.entryinvalid', ['attribute' => $attribute]); } list($login, $domain) = explode('@', $email); // Check if domain exists $domain = Domain::where('namespace', Str::lower($domain))->first(); if (empty($domain)) { return \trans('validation.domaininvalid'); } // Validate login part alone $v = Validator::make( [$attribute => $login], [$attribute => ['required', new UserEmailLocal(!$domain->isPublic())]] ); if ($v->fails()) { return $v->errors()->toArray()[$attribute][0]; } // Check if it is one of domains available to the user // TODO: We should have a helper that returns "flat" array with domain names // I guess we could use pluck() somehow $domains = array_map( function ($domain) { return $domain->namespace; }, $user->domains() ); if (!in_array($domain->namespace, $domains)) { return \trans('validation.entryexists', ['attribute' => 'domain']); } // Check if user with specified address already exists if (User::findByEmail($email)) { return \trans('validation.entryexists', ['attribute' => $attribute]); } return null; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 43ebdcbd..e5384dcf 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,211 +1,223 @@ 0, 'currency' => 'CHF' ]; protected $fillable = [ 'currency' ]; protected $nullable = [ 'description', ]; 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); } } public function chargeEntitlements($apply = true) { $charges = 0; $discount = $this->discount ? $this->discount->discount : 0; $discount = (100 - $discount) / 100; foreach ($this->entitlements()->get()->fresh() as $entitlement) { // 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)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // created more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $charges += $cost; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); $entitlement->save(); // TODO: This would be better done out of the loop (debit() will call save()), // but then, maybe we should use a db transaction $this->debit($cost); } } return $charges; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * 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); } } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * * @return Wallet Self */ public function credit(int $amount): Wallet { $this->balance += $amount; $this->save(); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * * @return Wallet Self */ public function debit(int $amount): Wallet { $this->balance -= $amount; $this->save(); return $this; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * 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('App\User', 'user_id', 'id'); } /** * Payments on this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function payments() { return $this->hasMany('App\Payment'); } + + /** + * Any (additional) properties of this wallet. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function settings() + { + return $this->hasMany('App\WalletSetting'); + } } diff --git a/src/app/WalletSetting.php b/src/app/WalletSetting.php new file mode 100644 index 00000000..c75fda6f --- /dev/null +++ b/src/app/WalletSetting.php @@ -0,0 +1,34 @@ +belongsTo( + '\App\Wallet', + 'wallet_id', /* local */ + 'id' /* remote */ + ); + } +} diff --git a/src/composer.json b/src/composer.json index 29e2a218..84a46ae6 100644 --- a/src/composer.json +++ b/src/composer.json @@ -1,85 +1,86 @@ { "name": "laravel/laravel", "type": "project", "description": "The Laravel Framework.", "keywords": [ "framework", "laravel" ], "license": "MIT", "repositories": [ { "type": "vcs", "url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git" } ], "require": { "php": "^7.1.3", "doctrine/dbal": "^2.9", "fideloper/proxy": "^4.0", "geoip2/geoip2": "^2.9", "iatstuti/laravel-nullable-fields": "*", "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/tinker": "^1.0", "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", "spomky-labs/otphp": "~4.0.0", + "stripe/stripe-php": "^7.29", "swooletw/laravel-swoole": "^2.6", "torann/currency": "^1.0", "torann/geoip": "^1.0", "tymon/jwt-auth": "^1.0" }, "require-dev": { "beyondcode/laravel-dump-server": "^1.0", "beyondcode/laravel-er-diagram-generator": "^1.3", "filp/whoops": "^2.0", "fzaninotto/faker": "^1.4", "laravel/dusk": "~5.11.0", "mockery/mockery": "^1.0", "nunomaduro/larastan": "^0.5", "phpstan/phpstan": "^0.12", "phpunit/phpunit": "^8" }, "config": { "optimize-autoloader": true, "preferred-install": "dist", "sort-packages": true }, "extra": { "laravel": { "dont-discover": [] } }, "autoload": { "psr-4": { "App\\": "app/" }, "classmap": [ "database/seeds", "database/factories", "include" ] }, "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }, "minimum-stability": "dev", "prefer-stable": true, "scripts": { "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], "post-create-project-cmd": [ "@php artisan key:generate --ansi" ] } } diff --git a/src/config/services.php b/src/config/services.php index 8ce6cc63..a1936570 100644 --- a/src/config/services.php +++ b/src/config/services.php @@ -1,37 +1,49 @@ [ 'domain' => env('MAILGUN_DOMAIN'), 'secret' => env('MAILGUN_SECRET'), 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), ], 'postmark' => [ 'token' => env('POSTMARK_TOKEN'), ], 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], 'sparkpost' => [ 'secret' => env('SPARKPOST_SECRET'), ], + 'payment_provider' => env('PAYMENT_PROVIDER', 'mollie'), + + 'mollie' => [ + 'key' => env('MOLLIE_KEY'), + ], + + 'stripe' => [ + 'key' => env('STRIPE_KEY'), + 'public_key' => env('STRIPE_PUBLIC_KEY'), + 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + ], + ]; diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php index 2f2b77cc..59a4a97f 100644 --- a/src/database/migrations/2020_03_16_100000_create_payments.php +++ b/src/database/migrations/2020_03_16_100000_create_payments.php @@ -1,40 +1,40 @@ string('id', 16)->primary(); + $table->string('id', 32)->primary(); $table->string('wallet_id', 36); $table->string('status', 16); $table->integer('amount'); $table->text('description'); $table->timestamps(); $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); } ); } /** * Reverse the migrations. * * @return void */ public function down() { Schema::dropIfExists('payments'); } } diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php similarity index 62% copy from src/database/migrations/2020_03_16_100000_create_payments.php copy to src/database/migrations/2020_04_21_100000_create_wallet_settings.php index 2f2b77cc..309680ef 100644 --- a/src/database/migrations/2020_03_16_100000_create_payments.php +++ b/src/database/migrations/2020_04_21_100000_create_wallet_settings.php @@ -1,40 +1,40 @@ string('id', 16)->primary(); - $table->string('wallet_id', 36); - $table->string('status', 16); - $table->integer('amount'); - $table->text('description'); + $table->bigIncrements('id'); + $table->string('wallet_id'); + $table->string('key'); + $table->string('value'); $table->timestamps(); $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); + $table->unique(['wallet_id', 'key']); } ); } /** * Reverse the migrations. * * @return void */ public function down() { - Schema::dropIfExists('payments'); + Schema::dropIfExists('wallet_settings'); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index f9b0c84c..0f2e6942 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,272 +1,296 @@ /** * 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 store from './store' +const loader = '
Loading
' + const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isLoading: true, isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, - // Display "loading" overlay (to be used by route components) + // Display "loading" overlay inside of the specified element + addLoader(elem) { + $(elem).css({position: 'relative'}).append($(loader).addClass('small')) + }, + // Remove loader element added in addLoader() + removeLoader(elem) { + $(elem).find('.app-loader').remove() + }, startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').show() if (!loading.length) { - $('#app').append($('
Loading
')) + $('#app').append($(loader)) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // 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". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#app').children(':not(nav)').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { this.errorPage(error.response.status, error.response.statusText) } }, price(price) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) +// Add a axios request interceptor +window.axios.interceptors.request.use( + config => { + // This is the only way I found to change configuration options + // on a running application. We need this for browser testing. + config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider + + return config + }, + error => { + // Do something with request error + return Promise.reject(error) + } +) + // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { let error_msg let status = error.response ? error.response.status : 200 if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { - const input_name = (form.data('validation-prefix') || '') + idx + const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { 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 ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js index 6ebbb073..c4ce8672 100644 --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -1,51 +1,53 @@ import { library } from '@fortawesome/fontawesome-svg-core' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' //import { } from '@fortawesome/free-brands-svg-icons' import { faCheckSquare, + faCreditCard, faSquare, } from '@fortawesome/free-regular-svg-icons' import { faCheck, faCheckCircle, faGlobe, faExclamationCircle, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet } from '@fortawesome/free-solid-svg-icons' // Register only these icons we need library.add( faCheck, faCheckCircle, faCheckSquare, + faCreditCard, faExclamationCircle, faGlobe, faInfoCircle, faLock, faKey, faPlus, faSearch, faSignInAlt, faSquare, faSyncAlt, faTrashAlt, faUser, faUserCog, faUsers, faWallet ) export default FontAwesomeIcon diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index f139b44a..f54c8dcb 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,39 +1,42 @@ 'The auto-payment has been removed.', + 'mandate-update-success' => 'The auto-payment has been updated.', + 'planbutton' => 'Choose :plan', '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-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', '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.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'wallet-update-success' => 'User wallet updated successfully.', ]; diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php index 9b6524b8..d459d2a6 100644 --- a/src/resources/lang/en/validation.php +++ b/src/resources/lang/en/validation.php @@ -1,166 +1,166 @@ 'The :attribute must be accepted.', 'active_url' => 'The :attribute is not a valid URL.', 'after' => 'The :attribute must be a date after :date.', 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'alpha' => 'The :attribute may only contain letters.', 'alpha_dash' => 'The :attribute may only contain letters, numbers, dashes and underscores.', 'alpha_num' => 'The :attribute may only contain letters and numbers.', 'array' => 'The :attribute must be an array.', 'before' => 'The :attribute must be a date before :date.', 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'between' => [ 'numeric' => 'The :attribute must be between :min and :max.', 'file' => 'The :attribute must be between :min and :max kilobytes.', 'string' => 'The :attribute must be between :min and :max characters.', 'array' => 'The :attribute must have between :min and :max items.', ], 'boolean' => 'The :attribute field must be true or false.', 'confirmed' => 'The :attribute confirmation does not match.', 'date' => 'The :attribute is not a valid date.', 'date_equals' => 'The :attribute must be a date equal to :date.', 'date_format' => 'The :attribute does not match the format :format.', 'different' => 'The :attribute and :other must be different.', 'digits' => 'The :attribute must be :digits digits.', 'digits_between' => 'The :attribute must be between :min and :max digits.', 'dimensions' => 'The :attribute has invalid image dimensions.', 'distinct' => 'The :attribute field has a duplicate value.', 'email' => 'The :attribute must be a valid email address.', 'ends_with' => 'The :attribute must end with one of the following: :values', 'exists' => 'The selected :attribute is invalid.', 'file' => 'The :attribute must be a file.', 'filled' => 'The :attribute field must have a value.', 'gt' => [ 'numeric' => 'The :attribute must be greater than :value.', 'file' => 'The :attribute must be greater than :value kilobytes.', 'string' => 'The :attribute must be greater than :value characters.', 'array' => 'The :attribute must have more than :value items.', ], 'gte' => [ 'numeric' => 'The :attribute must be greater than or equal :value.', 'file' => 'The :attribute must be greater than or equal :value kilobytes.', 'string' => 'The :attribute must be greater than or equal :value characters.', 'array' => 'The :attribute must have :value items or more.', ], 'image' => 'The :attribute must be an image.', 'in' => 'The selected :attribute is invalid.', 'in_array' => 'The :attribute field does not exist in :other.', 'integer' => 'The :attribute must be an integer.', 'ip' => 'The :attribute must be a valid IP address.', 'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv6' => 'The :attribute must be a valid IPv6 address.', 'json' => 'The :attribute must be a valid JSON string.', 'lt' => [ 'numeric' => 'The :attribute must be less than :value.', 'file' => 'The :attribute must be less than :value kilobytes.', 'string' => 'The :attribute must be less than :value characters.', 'array' => 'The :attribute must have less than :value items.', ], 'lte' => [ 'numeric' => 'The :attribute must be less than or equal :value.', 'file' => 'The :attribute must be less than or equal :value kilobytes.', 'string' => 'The :attribute must be less than or equal :value characters.', 'array' => 'The :attribute must not have more than :value items.', ], 'max' => [ 'numeric' => 'The :attribute may not be greater than :max.', 'file' => 'The :attribute may not be greater than :max kilobytes.', 'string' => 'The :attribute may not be greater than :max characters.', 'array' => 'The :attribute may not have more than :max items.', ], 'mimes' => 'The :attribute must be a file of type: :values.', 'mimetypes' => 'The :attribute must be a file of type: :values.', 'min' => [ 'numeric' => 'The :attribute must be at least :min.', 'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute must be at least :min characters.', 'array' => 'The :attribute must have at least :min items.', ], 'not_in' => 'The selected :attribute is invalid.', 'not_regex' => 'The :attribute format is invalid.', 'numeric' => 'The :attribute must be a number.', 'present' => 'The :attribute field must be present.', 'regex' => 'The :attribute format is invalid.', 'required' => 'The :attribute field is required.', 'required_if' => 'The :attribute field is required when :other is :value.', 'required_unless' => 'The :attribute field is required unless :other is in :values.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_with_all' => 'The :attribute field is required when :values are present.', 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', 'same' => 'The :attribute and :other must match.', 'size' => [ 'numeric' => 'The :attribute must be :size.', 'file' => 'The :attribute must be :size kilobytes.', 'string' => 'The :attribute must be :size characters.', 'array' => 'The :attribute must contain :size items.', ], 'starts_with' => 'The :attribute must start with one of the following: :values', 'string' => 'The :attribute must be a string.', 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'uploaded' => 'The :attribute failed to upload.', 'url' => 'The :attribute format is invalid.', 'uuid' => 'The :attribute must be a valid UUID.', - '2fareq' => 'Second factor code is required.', '2fainvalid' => 'Second factor code is invalid.', 'emailinvalid' => 'The specified email address is invalid.', 'domaininvalid' => 'The specified domain is invalid.', 'logininvalid' => 'The specified login is invalid.', 'loginexists' => 'The specified login is not available.', 'domainexists' => 'The specified domain is not available.', 'noemailorphone' => 'The specified text is neither a valid email address nor a phone number.', 'packageinvalid' => 'Invalid package selected.', 'packagerequired' => 'Package is required.', 'usernotexists' => 'Unable to find user.', 'voucherinvalid' => 'The voucher code is invalid or expired.', 'noextemail' => 'This user has no external email address.', 'entryinvalid' => 'The specified :attribute is invalid.', 'entryexists' => 'The specified :attribute is not available.', + 'minamount' => 'Minimum amount for a single payment is :amount.', /* |-------------------------------------------------------------------------- | Custom Validation Language Lines |-------------------------------------------------------------------------- | | Here you may specify custom validation messages for attributes using the | convention "attribute.rule" to name the lines. This makes it quick to | specify a specific custom language line for a given attribute rule. | */ 'custom' => [ 'attribute-name' => [ 'rule-name' => 'custom-message', ], ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes |-------------------------------------------------------------------------- | | The following language lines are used to swap our attribute placeholder | with something more reader friendly such as "E-Mail Address" instead | of "email". This simply helps us make our message more expressive. | */ 'attributes' => [], ]; diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index 1d53f05f..5c7423ab 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,221 +1,248 @@ // Fonts // Variables @import 'variables'; // Bootstrap @import '~bootstrap/scss/bootstrap'; @import 'menu'; @import 'toast'; @import 'forms'; html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } + + &.small .spinner-border { + width: 25px; + height: 25px; + border-width: 3px; + } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; height: 8em; td { vertical-align: middle; } tbody:not(:empty) + & { display: none; } } table { td.buttons, td.price, td.selection { width: 1%; } td.price { text-align: right; } &.form-list { td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } .plan-selector { .plan-ico { font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; margin-bottom: 1rem; border-radius: 50%; } ul { padding-left: 1.2em; &:last-child { margin-bottom: 0; } } } +.form-separator { + position: relative; + margin: 1em 0; + display: flex; + justify-content: center; + + hr { + border-color: #999; + margin: 0; + position: absolute; + top: .75em; + width: 100%; + } + + span { + background: #fff; + padding: 0 1em; + z-index: 1; + } +} + // Bootstrap style fix .btn-link { border: 0; } diff --git a/src/resources/sass/forms.scss b/src/resources/sass/forms.scss index f09e9ceb..5bc8b0cc 100644 --- a/src/resources/sass/forms.scss +++ b/src/resources/sass/forms.scss @@ -1,34 +1,44 @@ .list-input { & > div { &:not(:last-child) { margin-bottom: -1px; input, a.btn { border-bottom-right-radius: 0; border-bottom-left-radius: 0; } } &:not(:first-child) { input, a.btn { border-top-right-radius: 0; border-top-left-radius: 0; } } } input.is-invalid { z-index: 2; } } .range-input { display: flex; label { margin-right: 0.5em; } } + +.form-control-plaintext .btn-sm { + margin-top: -0.25rem; +} + +form.read-only { + .row { + margin-bottom: 0; + } +} diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index 0a706b0c..ba691696 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,456 +1,476 @@ diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index c4ee333b..88dc958d 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,42 +1,248 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 095df7d0..91aa0e4b 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,102 +1,106 @@ 'api', 'prefix' => 'auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); Route::post('payments', 'API\V4\PaymentsController@store'); + Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); + Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); + Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); + Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); } ); Route::group( [ 'domain' => \config('app.domain'), ], function () { - Route::post('webhooks/payment/mollie', 'API\V4\PaymentsController@webhook'); + Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); } ); diff --git a/src/tests/Browser.php b/src/tests/Browser.php index 6429052d..54c6e365 100644 --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -1,207 +1,207 @@ elements($selector); $count = count($elements); if ($visible) { foreach ($elements as $element) { if (!$element->isDisplayed()) { $count--; } } } - Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $count"); + Assert::assertEquals($expected_count, $count, "Count of [$selector] elements is not $expected_count"); return $this; } /** * Assert Tip element content */ public function assertTip($selector, $content) { return $this->click($selector) ->withinBody(function ($browser) use ($content) { $browser->waitFor('div.tooltip .tooltip-inner') ->assertSeeIn('div.tooltip .tooltip-inner', $content); }) ->click($selector); } /** * Assert Toast element content (and close it) */ public function assertToast(string $type, string $message, $title = null) { return $this->withinBody(function ($browser) use ($type, $title, $message) { $browser->with(new Toast($type), function (Browser $browser) use ($title, $message) { $browser->assertToastTitle($title) ->assertToastMessage($message) ->closeToast(); }); }); } /** * Assert specified error page is displayed. */ public function assertErrorPage(int $error_code) { $this->with(new Error($error_code), function ($browser) { // empty, assertions will be made by the Error component itself }); return $this; } /** * Assert that the given element has specified class assigned. */ public function assertHasClass($selector, $class_name) { $element = $this->resolver->findOrFail($selector); $classes = explode(' ', (string) $element->getAttribute('class')); Assert::assertContains($class_name, $classes, "[$selector] has no class '{$class_name}'"); return $this; } /** * Assert that the given element is readonly */ public function assertReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value == 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element is not readonly */ public function assertNotReadonly($selector) { $element = $this->resolver->findOrFail($selector); $value = $element->getAttribute('readonly'); Assert::assertTrue($value != 'true', "Element [$selector] is not readonly"); return $this; } /** * Assert that the given element contains specified text, * no matter it's displayed or not. */ public function assertText($selector, $text) { $element = $this->resolver->findOrFail($selector); Assert::assertTrue(strpos($element->getText(), $text) !== false, "No expected text in [$selector]"); return $this; } /** * Remove all toast messages */ public function clearToasts() { $this->script("jQuery('.toast-container > *').remove()"); return $this; } /** * Check if in Phone mode */ public static function isPhone() { return getenv('TESTS_MODE') == 'phone'; } /** * Check if in Tablet mode */ public static function isTablet() { return getenv('TESTS_MODE') == 'tablet'; } /** * Check if in Desktop mode */ public static function isDesktop() { return !self::isPhone() && !self::isTablet(); } /** * Returns content of a downloaded file */ public function readDownloadedFile($filename) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download if (!file_exists($filename)) { sleep(2); } Assert::assertFileExists($filename); return file_get_contents($filename); } /** * Removes downloaded file */ public function removeDownloadedFile($filename) { @unlink(__DIR__ . "/Browser/downloads/$filename"); return $this; } /** * Execute code within body context. * Useful to execute code that selects elements outside of a component context */ public function withinBody($callback) { if ($this->resolver->prefix != 'body') { $orig_prefix = $this->resolver->prefix; $this->resolver->prefix = 'body'; } call_user_func($callback, $this); if (isset($orig_prefix)) { $this->resolver->prefix = $orig_prefix; } return $this; } } diff --git a/src/tests/Browser/Admin/UserTest.php b/src/tests/Browser/Admin/UserTest.php index be4d0ba7..b069bdb6 100644 --- a/src/tests/Browser/Admin/UserTest.php +++ b/src/tests/Browser/Admin/UserTest.php @@ -1,461 +1,468 @@ getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => '+48123123123', 'external_email' => 'john.doe.external@gmail.com', ]); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->balance = 0; $wallet->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $john->setSettings([ 'phone' => null, 'external_email' => 'john.doe.external@gmail.com', ]); $wallet = $john->wallets()->first(); $wallet->discount()->dissociate(); $wallet->balance = 0; $wallet->save(); parent::tearDown(); } /** * Test user info page (unauthenticated) */ public function testUserUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $browser->visit('/user/' . $jack->id)->on(new Home()); }); } /** * Test user info page */ public function testUserInfo(): void { $this->browse(function (Browser $browser) { $jack = $this->getTestUser('jack@kolab.org'); $page = new UserPage($jack->id); $browser->visit(new Home()) ->submitLogon('jeroen@jeroen.jeroen', 'jeroen', true) ->on(new Dashboard()) ->visit($page) ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $jack->email) ->with('@user-info form', function (Browser $browser) use ($jack) { $browser->assertElementsCount('.row', 7) ->assertSeeIn('.row:nth-child(1) label', 'Managed by') ->assertSeeIn('.row:nth-child(1) #manager a', 'john@kolab.org') ->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(2) #userid', "{$jack->id} ({$jack->created_at})") ->assertSeeIn('.row:nth-child(3) label', 'Status') ->assertSeeIn('.row:nth-child(3) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(4) label', 'First name') ->assertSeeIn('.row:nth-child(4) #first_name', 'Jack') ->assertSeeIn('.row:nth-child(5) label', 'Last name') ->assertSeeIn('.row:nth-child(5) #last_name', 'Daniels') ->assertSeeIn('.row:nth-child(6) label', 'External email') ->assertMissing('.row:nth-child(6) #external_email a') ->assertSeeIn('.row:nth-child(7) label', 'Country') ->assertSeeIn('.row:nth-child(7) #country', 'United States of America'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { - $browser->assertSeeIn('.card-title', 'Account balance') + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 1) + $payment_provider = ucfirst(\config('services.payment_provider')); + $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'Discount') - ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); + ->assertSeeIn('.row:nth-child(1) #discount span', 'none') + ->assertSeeIn('.row:nth-child(2) label', $payment_provider . ' ID') + ->assertVisible('.row:nth-child(2) a'); }); }); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'jack.daniels@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF') ->assertMissing('table tfoot'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); }); } /** * Test user info page (continue) * * @depends testUserInfo */ public function testUserInfo2(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $page = new UserPage($john->id); $discount = Discount::where('code', 'TEST')->first(); $wallet = $john->wallet(); $wallet->discount()->associate($discount); $wallet->debit(2010); $wallet->save(); // Click the managed-by link on Jack's page $browser->click('@user-info #manager a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $john->email) ->with('@user-info form', function (Browser $browser) use ($john) { $ext_email = $john->getSetting('external_email'); $browser->assertElementsCount('.row', 9) ->assertSeeIn('.row:nth-child(1) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(1) #userid', "{$john->id} ({$john->created_at})") ->assertSeeIn('.row:nth-child(2) label', 'Status') ->assertSeeIn('.row:nth-child(2) #status span.text-success', 'Active') ->assertSeeIn('.row:nth-child(3) label', 'First name') ->assertSeeIn('.row:nth-child(3) #first_name', 'John') ->assertSeeIn('.row:nth-child(4) label', 'Last name') ->assertSeeIn('.row:nth-child(4) #last_name', 'Doe') ->assertSeeIn('.row:nth-child(5) label', 'Organization') ->assertSeeIn('.row:nth-child(5) #organization', 'Kolab Developers') ->assertSeeIn('.row:nth-child(6) label', 'Phone') ->assertSeeIn('.row:nth-child(6) #phone', $john->getSetting('phone')) ->assertSeeIn('.row:nth-child(7) label', 'External email') ->assertSeeIn('.row:nth-child(7) #external_email a', $ext_email) ->assertAttribute('.row:nth-child(7) #external_email a', 'href', "mailto:$ext_email") ->assertSeeIn('.row:nth-child(8) label', 'Address') ->assertSeeIn('.row:nth-child(8) #billing_address', $john->getSetting('billing_address')) ->assertSeeIn('.row:nth-child(9) label', 'Country') ->assertSeeIn('.row:nth-child(9) #country', 'United States of America'); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { - $browser->assertSeeIn('.card-title', 'Account balance') + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-danger', '-20,10 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 1) + $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', '10% - Test voucher'); }); }); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (1)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 1) ->assertSeeIn('table tbody tr:first-child td:first-child', 'john.doe@kolab.org') ->assertMissing('table tfoot'); }); // Assert Subscriptions tab $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (3)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 3) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // Assert Domains tab $browser->assertSeeIn('@nav #tab-domains', 'Domains (1)') ->click('@nav #tab-domains') ->with('@user-domains table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 1) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertMissing('tfoot'); }); // Assert Users tab $browser->assertSeeIn('@nav #tab-users', 'Users (3)') ->click('@nav #tab-users') ->with('@user-users table', function (Browser $browser) { $browser->assertElementsCount('tbody tr', 3) ->assertSeeIn('tbody tr:nth-child(1) td:first-child a', 'jack@kolab.org') ->assertVisible('tbody tr:nth-child(1) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(2) td:first-child a', 'joe@kolab.org') ->assertVisible('tbody tr:nth-child(2) td:first-child svg.text-success') ->assertSeeIn('tbody tr:nth-child(3) td:first-child a', 'ned@kolab.org') ->assertVisible('tbody tr:nth-child(3) td:first-child svg.text-success') ->assertMissing('tfoot'); }); }); // Now we go to Ned's info page, he's a controller on John's wallet $this->browse(function (Browser $browser) { $ned = $this->getTestUser('ned@kolab.org'); $page = new UserPage($ned->id); $browser->click('@user-users tbody tr:nth-child(3) td:first-child a') ->on($page); // Assert main info box content $browser->assertSeeIn('@user-info .card-title', $ned->email) ->with('@user-info form', function (Browser $browser) use ($ned) { $browser->assertSeeIn('.row:nth-child(2) label', 'ID (Created at)') ->assertSeeIn('.row:nth-child(2) #userid', "{$ned->id} ({$ned->created_at})"); }); // Some tabs are loaded in background, wait a second $browser->pause(500) ->assertElementsCount('@nav a', 5); // Assert Finances tab $browser->assertSeeIn('@nav #tab-finances', 'Finances') ->with('@user-finances', function (Browser $browser) { - $browser->assertSeeIn('.card-title', 'Account balance') + $browser->waitUntilMissing('.app-loader') + ->assertSeeIn('.card-title', 'Account balance') ->assertSeeIn('.card-title .text-success', '0,00 CHF') ->with('form', function (Browser $browser) { - $browser->assertElementsCount('.row', 1) + $browser->assertElementsCount('.row', 2) ->assertSeeIn('.row:nth-child(1) label', 'Discount') ->assertSeeIn('.row:nth-child(1) #discount span', 'none'); }); }); // Assert Aliases tab $browser->assertSeeIn('@nav #tab-aliases', 'Aliases (0)') ->click('@nav #tab-aliases') ->whenAvailable('@user-aliases', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'This user has no email aliases.'); }); // Assert Subscriptions tab, we expect John's discount here $browser->assertSeeIn('@nav #tab-subscriptions', 'Subscriptions (5)') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 5) ->assertSeeIn('table tbody tr:nth-child(1) td:first-child', 'User Mailbox') ->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:first-child', 'Storage Quota 2 GB') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:first-child', 'Groupware Features') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(4) td:first-child', 'Activesync') ->assertSeeIn('table tbody tr:nth-child(4) td:last-child', '0,90 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(5) td:first-child', '2-Factor Authentication') ->assertSeeIn('table tbody tr:nth-child(5) td:last-child', '0,00 CHF/month¹') ->assertMissing('table tfoot') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }); // We don't expect John's domains here $browser->assertSeeIn('@nav #tab-domains', 'Domains (0)') ->click('@nav #tab-domains') ->with('@user-domains', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no domains in this account.'); }); // We don't expect John's users here $browser->assertSeeIn('@nav #tab-users', 'Users (0)') ->click('@nav #tab-users') ->with('@user-users', function (Browser $browser) { $browser->assertElementsCount('table tbody tr', 0) ->assertSeeIn('table tfoot tr td', 'There are no users in this account.'); }); }); } /** * Test editing an external email * * @depends testUserInfo2 */ public function testExternalEmail(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->waitFor('@user-info #external_email button') ->click('@user-info #external_email button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'External email') ->assertFocused('@body input') ->assertValue('@body input', 'john.doe.external@gmail.com') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#email-dialog') ->click('@user-info #external_email button') // Test email validation error handling, and email update ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->type('@body input', 'test') ->click('@button-action') ->waitFor('@body input.is-invalid') ->assertSeeIn( '@body input + .invalid-feedback', 'The external email must be a valid email address.' ) ->assertToast(Toast::TYPE_ERROR, 'Form validation error') ->type('@body input', 'test@test.com') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User data updated successfully.') ->assertSeeIn('@user-info #external_email a', 'test@test.com') ->click('@user-info #external_email button') ->with(new Dialog('#email-dialog'), function (Browser $browser) { $browser->assertValue('@body input', 'test@test.com') ->assertMissing('@body input.is-invalid') ->assertMissing('@body input + .invalid-feedback') ->click('@button-cancel'); }) ->assertSeeIn('@user-info #external_email a', 'test@test.com'); // $john->getSetting() may not work here as it uses internal cache // read the value form database $current_ext_email = $john->settings()->where('key', 'external_email')->first()->value; $this->assertSame('test@test.com', $current_ext_email); }); } /** * Test editing wallet discount * * @depends testUserInfo2 */ public function testWalletDiscount(): void { $this->browse(function (Browser $browser) { $john = $this->getTestUser('john@kolab.org'); $browser->visit(new UserPage($john->id)) ->pause(100) + ->waitUntilMissing('@user-finances .app-loader') ->click('@user-finances #discount button') // Test dialog content, and closing it with Cancel button ->with(new Dialog('#discount-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Account discount') ->assertFocused('@body select') ->assertSelected('@body select', '') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Submit') ->click('@button-cancel'); }) ->assertMissing('#discount-dialog') ->click('@user-finances #discount button') // Change the discount ->with(new Dialog('#discount-dialog'), function (Browser $browser) { $browser->click('@body select') ->click('@body select option:nth-child(2)') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') ->assertSeeIn('#discount span', '10% - Test voucher') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '3,99 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month¹') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '4,99 CHF/month¹') ->assertSeeIn('table + .hint', '¹ applied discount: 10% - Test voucher'); }) // Change back to 'none' ->click('@nav #tab-finances') ->click('@user-finances #discount button') ->with(new Dialog('#discount-dialog'), function (Browser $browser) { $browser->click('@body select') ->click('@body select option:nth-child(1)') ->click('@button-action'); }) ->assertToast(Toast::TYPE_SUCCESS, 'User wallet updated successfully.') ->assertSeeIn('#discount span', 'none') ->click('@nav #tab-subscriptions') ->with('@user-subscriptions', function (Browser $browser) { $browser->assertSeeIn('table tbody tr:nth-child(1) td:last-child', '4,44 CHF/month') ->assertSeeIn('table tbody tr:nth-child(2) td:last-child', '0,00 CHF/month') ->assertSeeIn('table tbody tr:nth-child(3) td:last-child', '5,55 CHF/month') ->assertMissing('table + .hint'); }); }); } } diff --git a/src/tests/Browser/Pages/DomainInfo.php b/src/tests/Browser/Pages/DomainInfo.php index e57966fe..b7f989d3 100644 --- a/src/tests/Browser/Pages/DomainInfo.php +++ b/src/tests/Browser/Pages/DomainInfo.php @@ -1,46 +1,45 @@ waitUntilMissing('@app .app-loader') - ->assertPresent('@config,@verify'); + $browser->waitUntilMissing('@app .app-loader'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@config' => '#domain-config', '@verify' => '#domain-verify', '@status' => '#status-box', ]; } } diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php index 7dd18d41..95eb36cc 100644 --- a/src/tests/Browser/Pages/Home.php +++ b/src/tests/Browser/Pages/Home.php @@ -1,73 +1,85 @@ waitForLocation($this->url()) ->assertVisible('form.form-signin'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements() { return [ '@app' => '#app', '@email-input' => '#inputEmail', '@password-input' => '#inputPassword', '@second-factor-input' => '#secondfactor', ]; } /** * Submit logon form. * * @param \Laravel\Dusk\Browser $browser The browser object * @param string $username User name * @param string $password User password * @param bool $wait_for_dashboard + * @param array $config Client-site config * * @return void */ - public function submitLogon($browser, $username, $password, $wait_for_dashboard = false) - { + public function submitLogon( + $browser, + $username, + $password, + $wait_for_dashboard = false, + $config = [] + ) { $browser->type('@email-input', $username) ->type('@password-input', $password); if ($username == 'ned@kolab.org') { $code = \App\Auth\SecondFactor::code('ned@kolab.org'); $browser->type('@second-factor-input', $code); } + if (!empty($config)) { + $browser->script( + sprintf('Object.assign(window.config, %s)', \json_encode($config)) + ); + } + $browser->press('form button'); if ($wait_for_dashboard) { $browser->waitForLocation('/dashboard'); } } } diff --git a/src/tests/Browser/Pages/PaymentMollie.php b/src/tests/Browser/Pages/PaymentMollie.php index 09c7957b..b5de81de 100644 --- a/src/tests/Browser/Pages/PaymentMollie.php +++ b/src/tests/Browser/Pages/PaymentMollie.php @@ -1,46 +1,64 @@ waitFor('#container'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@form' => '#container', '@title' => '#container .header__info', '@amount' => '#container .header__amount', '@methods' => '#payment-method-list', '@status-table' => 'table.table--select-status', ]; } + + /** + * Submit payment form. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function submitValidCreditCard($browser) + { + if ($browser->element('@methods')) { + $browser->click('@methods button.grid-button-creditcard') + ->waitFor('button.form__button'); + } + + $browser->click('@status-table input[value="paid"]') + ->click('button.form__button'); + } } diff --git a/src/tests/Browser/Pages/PaymentStripe.php b/src/tests/Browser/Pages/PaymentStripe.php new file mode 100644 index 00000000..73396a4b --- /dev/null +++ b/src/tests/Browser/Pages/PaymentStripe.php @@ -0,0 +1,67 @@ +waitFor('.App-Payment'); + } + + /** + * Get the element shortcuts for the page. + * + * @return array + */ + public function elements(): array + { + return [ + '@form' => '.App-Payment > form', + '@title' => '.App-Overview .ProductSummary-Info .Text', + '@amount' => '#ProductSummary-TotalAmount', + '@description' => '#ProductSummary-Description', + '@email-input' => '.App-Payment #email', + '@cardnumber-input' => '.App-Payment #cardNumber', + '@cardexpiry-input' => '.App-Payment #cardExpiry', + '@cardcvc-input' => '.App-Payment #cardCvc', + '@name-input' => '.App-Payment #billingName', + '@submit-button' => '.App-Payment form button.SubmitButton', + ]; + } + + /** + * Submit payment form. + * + * @param \Laravel\Dusk\Browser $browser The browser object + * + * @return void + */ + public function submitValidCreditCard($browser) + { + $browser->type('@name-input', 'Test') + ->type('@cardnumber-input', '4242424242424242') + ->type('@cardexpiry-input', '12/' . (date('y') + 1)) + ->type('@cardcvc-input', '123') + ->press('@submit-button'); + } +} diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php index 239fd639..8db9f01d 100644 --- a/src/tests/Browser/Pages/Wallet.php +++ b/src/tests/Browser/Pages/Wallet.php @@ -1,45 +1,46 @@ assertPathIs($this->url()) ->waitUntilMissing('@app .app-loader') ->assertSeeIn('#wallet .card-title', 'Account balance'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', - '@main' => '#wallet' + '@main' => '#wallet', + '@payment-dialog' => '#payment-dialog', ]; } } diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php new file mode 100644 index 00000000..96b59533 --- /dev/null +++ b/src/tests/Browser/PaymentMollieTest.php @@ -0,0 +1,212 @@ +deleteTestUser('payment-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('payment-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the payment process + * + * @group mollie + */ + public function testPayment(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main button', 'Add credit') + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertFocused('#amount') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #payment-form button', 'Continue') + // Test error handling + ->type('@body #amount', 'aaa') + ->click('@body #payment-form button') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #amount', '12.34') + ->click('@body #payment-form button'); + }) + ->on(new PaymentMollie()) + ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@amount', 'CHF 12.34'); + + // Looks like the Mollie testing mode is limited. + // We'll select credit card method and mark the payment as paid + // We can't do much more, we have to trust Mollie their page works ;) + + // For some reason I don't get the method selection form, it + // immediately jumps to the next step. Let's detect that + if ($browser->element('@methods')) { + $browser->click('@methods button.grid-button-creditcard') + ->waitFor('button.form__button'); + } + + $browser->click('@status-table input[value="paid"]') + ->click('button.form__button'); + + // Now it should redirect back to wallet page and in background + // use the webhook to update payment status (and balance). + + // Looks like in test-mode the webhook is executed before redirect + // so we can expect balance updated on the wallet page + + $browser->waitForLocation('/wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF'); + }); + } + + /** + * Test the auto-payment setup process + * + * @group mollie + */ + public function testAutoPaymentSetup(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment') + ->click('@body #mandate-form button') + ->assertSeeIn('@title', 'Add auto-payment') + ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by') + ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100) + ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Continue') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '-1') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertVisible('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.') + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '0') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertMissing('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertMissing('#mandate_balance + span + .invalid-feedback') + // Submit valid data + ->type('@body #mandate_amount', '100') + ->type('@body #mandate_balance', '0') + ->click('@button-action'); + }) + ->on(new PaymentMollie()) + ->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup') + ->assertMissing('@amount') + ->submitValidCreditCard() + ->waitForLocation('/wallet') + ->visit('/wallet?paymentProvider=mollie') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $expected = 'Auto-payment is set to fill up your account by 100 CHF every' + . ' time your account balance gets under 0 CHF. You will be charged' + . ' via Mastercard (**** **** **** 6787).'; + + $browser->assertSeeIn('@title', 'Top up your wallet') + ->waitFor('#mandate-info') + ->assertSeeIn('#mandate-info p:first-child', $expected) + ->click('@button-cancel'); + }); + }); + + // Test updating auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment') + ->click('@body #mandate-info button.btn-primary') + ->assertSeeIn('@title', 'Update auto-payment') + ->assertValue('@body #mandate_amount', '100') + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #mandate_amount', '50') + ->click('@button-action'); + }) + ->waitUntilMissing('#payment-dialog') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.'); + }); + + // Test deleting auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment') + ->click('@body #mandate-info button.btn-danger') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') + ->assertVisible('@body #mandate-form') + ->assertMissing('@body #mandate-info'); + }); + }); + } +} diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php new file mode 100644 index 00000000..a75273da --- /dev/null +++ b/src/tests/Browser/PaymentStripeTest.php @@ -0,0 +1,202 @@ +deleteTestUser('payment-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('payment-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the payment process + * + * @group stripe + */ + public function testPayment(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) use ($user) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main button', 'Add credit') + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertFocused('#amount') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #payment-form button', 'Continue') + // Test error handling + ->type('@body #amount', 'aaa') + ->click('@body #payment-form button') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #amount', '12.34') + ->click('@body #payment-form button'); + }) + ->on(new PaymentStripe()) + ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@amount', 'CHF 12.34') + ->assertValue('@email-input', $user->email) + ->submitValidCreditCard(); + + // Now it should redirect back to wallet page and in background + // use the webhook to update payment status (and balance). + + // Looks like in test-mode the webhook is executed before redirect + // so we can expect balance updated on the wallet page + + $browser->waitForLocation('/wallet', 15) // need more time than default 5 sec. + ->on(new WalletPage()) + ->assertSeeIn('@main .card-text', 'Current account balance is 12,34 CHF'); + }); + } + + /** + * Test the auto-payment setup process + * + * @group stripe + */ + public function testAutoPaymentSetup(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + // Test creating auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@body #mandate-form button', 'Set up auto-payment') + ->click('@body #mandate-form button') + ->assertSeeIn('@title', 'Add auto-payment') + ->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by') + ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100) + ->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Continue') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '-1') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertVisible('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.') + ->type('@body #mandate_amount', 'aaa') + ->type('@body #mandate_balance', '0') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertMissing('@body #mandate_balance.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + ->assertMissing('#mandate_balance + span + .invalid-feedback') + // Submit valid data + ->type('@body #mandate_amount', '100') + ->type('@body #mandate_balance', '0') + ->click('@button-action'); + }) + ->on(new PaymentStripe()) + ->assertMissing('@title') + ->assertMissing('@amount') + ->assertValue('@email-input', $user->email) + ->submitValidCreditCard() + ->waitForLocation('/wallet', 15) // need more time than default 5 sec. + ->visit('/wallet?paymentProvider=stripe') + ->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $expected = 'Auto-payment is set to fill up your account by 100 CHF every' + . ' time your account balance gets under 0 CHF. You will be charged' + . ' via Visa (**** **** **** 4242).'; + + $browser->assertSeeIn('@title', 'Top up your wallet') + ->waitFor('#mandate-info') + ->assertSeeIn('#mandate-info p:first-child', $expected) + ->click('@button-cancel'); + }); + }); + + // Test updating auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@body #mandate-info button.btn-primary', 'Change auto-payment') + ->click('@body #mandate-info button.btn-primary') + ->assertSeeIn('@title', 'Update auto-payment') + ->assertValue('@body #mandate_amount', '100') + ->assertValue('@body #mandate_balance', '0') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Submit') + // Test error handling + ->type('@body #mandate_amount', 'aaa') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertVisible('@body #mandate_amount.is-invalid') + ->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #mandate_amount', '50') + ->click('@button-action'); + }) + ->waitUntilMissing('#payment-dialog') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.'); + }); + + // Test deleting auto-payment + $this->browse(function (Browser $browser) use ($user) { + $browser->on(new WalletPage()) + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@body #mandate-info button.btn-danger', 'Cancel auto-payment') + ->click('@body #mandate-info button.btn-danger') + ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') + ->assertVisible('@body #mandate-form') + ->assertMissing('@body #mandate-info'); + }); + }); + } +} diff --git a/src/tests/Browser/PaymentTest.php b/src/tests/Browser/PaymentTest.php deleted file mode 100644 index b0491cbf..00000000 --- a/src/tests/Browser/PaymentTest.php +++ /dev/null @@ -1,82 +0,0 @@ -deleteTestUser('payment-test@kolabnow.com'); - } - - /** - * {@inheritDoc} - */ - public function tearDown(): void - { - $this->deleteTestUser('payment-test@kolabnow.com'); - - parent::tearDown(); - } - - /** - * Test a payment process - * - * @group mollie - */ - public function testPayment(): void - { - $user = $this->getTestUser('payment-test@kolabnow.com', [ - 'password' => 'simple123', - ]); - - $this->browse(function (Browser $browser) { - $browser->visit(new Home()) - ->submitLogon('payment-test@kolabnow.com', 'simple123', true) - ->on(new Dashboard()) - ->click('@links .link-wallet') - ->on(new WalletPage()) - ->click('@main button') - ->on(new PaymentMollie()) - ->assertSeeIn('@title', 'Kolab Now Payment') - ->assertSeeIn('@amount', 'CHF 10.00'); - - // Looks like the Mollie testing mode is limited. - // We'll select credit card method and mark the payment as paid - // We can't do much more, we have to trust Mollie their page works ;) - - // For some reason I don't get the method selection form, it - // immediately jumps to the next step. Let's detect that - if ($browser->element('@methods')) { - $browser->click('@methods button.grid-button-creditcard') - ->waitFor('button.form__button'); - } - - $browser->click('@status-table input[value="paid"]') - ->click('button.form__button'); - - // Now it should redirect back to wallet page and in background - // use the webhook to update payment status (and balance). - - // Looks like in test-mode the webhook is executed before redirect - // so we can expect balance updated on the wallet page - - $browser->waitForLocation('/wallet') - ->on(new WalletPage()) - ->assertSeeIn('@main .card-text', 'Current account balance is 10,00 CHF'); - }); - } -} diff --git a/src/tests/Feature/Backends/LDAPTest.php b/src/tests/Feature/Backends/LDAPTest.php index b08bd922..2f2990ac 100644 --- a/src/tests/Feature/Backends/LDAPTest.php +++ b/src/tests/Feature/Backends/LDAPTest.php @@ -1,154 +1,153 @@ deleteTestUser('user-ldap-test@' . \config('app.domain')); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('user-ldap-test@' . \config('app.domain')); parent::tearDown(); } /** * Test creating/updating/deleting a domain record * * @group ldap */ public function testDomain(): void { $this->markTestIncomplete(); } /** * Test creating/editing/deleting a user record * * @group ldap */ public function testUser(): void { Queue::fake(); $user = $this->getTestUser('user-ldap-test@' . \config('app.domain')); LDAP::createUser($user); $ldap_user = LDAP::getUser($user->email); $expected = [ 'objectclass' => [ 'top', 'inetorgperson', 'inetuser', 'kolabinetorgperson', 'mailrecipient', 'person', 'organizationalPerson', ], 'mail' => $user->email, 'uid' => $user->email, 'nsroledn' => null, 'cn' => 'unknown', 'displayname' => '', 'givenname' => '', 'sn' => 'unknown', 'inetuserstatus' => $user->status, 'mailquota' => null, 'o' => '', 'alias' => null, ]; foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Add aliases, and change some user settings, and entitlements $user->setSettings([ 'first_name' => 'Firstname', 'last_name' => 'Lastname', 'organization' => 'Org', 'country' => 'PL', ]); $user->status |= User::STATUS_IMAP_READY; $user->save(); $aliases = ['t1-' . $user->email, 't2-' . $user->email]; $user->setAliases($aliases); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); LDAP::updateUser($user->fresh()); $expected['alias'] = $aliases; $expected['o'] = 'Org'; $expected['displayname'] = 'Lastname, Firstname'; $expected['givenname'] = 'Firstname'; $expected['cn'] = 'Firstname Lastname'; $expected['sn'] = 'Lastname'; $expected['inetuserstatus'] = $user->status; $expected['mailquota'] = 2097152; $expected['nsroledn'] = null; - // TODO: country? dn $ldap_user = LDAP::getUser($user->email); foreach ($expected as $attr => $value) { $this->assertEquals($value, isset($ldap_user[$attr]) ? $ldap_user[$attr] : null); } // Update entitlements $sku_activesync = \App\Sku::where('title', 'activesync')->first(); $sku_groupware = \App\Sku::where('title', 'groupware')->first(); $user->assignSku($sku_activesync, 1); Entitlement::where(['sku_id' => $sku_groupware->id, 'entitleable_id' => $user->id])->delete(); LDAP::updateUser($user->fresh()); $expected_roles = [ 'activesync-user', 'imap-user' ]; $ldap_user = LDAP::getUser($user->email); $this->assertCount(2, $ldap_user['nsroledn']); $ldap_roles = array_map( function ($role) { if (preg_match('/^cn=([a-z0-9-]+)/', $role, $m)) { return $m[1]; } else { return $role; } }, $ldap_user['nsroledn'] ); $this->assertSame($expected_roles, $ldap_roles); // Delete the user LDAP::deleteUser($user); $this->assertSame(null, LDAP::getUser($user->email)); } } diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php index 9c42edbf..5cacb275 100644 --- a/src/tests/Feature/Controller/Admin/WalletsTest.php +++ b/src/tests/Feature/Controller/Admin/WalletsTest.php @@ -1,70 +1,110 @@ 'stripe']); + + $user = $this->getTestUser('john@kolab.org'); + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $wallet = $user->wallets()->first(); + + // Make sure there's no stripe/mollie identifiers + $wallet->setSetting('stripe_id', null); + $wallet->setSetting('stripe_mandate_id', null); + $wallet->setSetting('mollie_id', null); + $wallet->setSetting('mollie_mandate_id', null); + + // Non-admin user + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Admin user + $response = $this->actingAs($admin)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($wallet->id, $json['id']); + $this->assertSame('CHF', $json['currency']); + $this->assertSame(0, $json['balance']); + $this->assertSame(0, $json['discount']); + $this->assertTrue(empty($json['description'])); + $this->assertTrue(empty($json['discount_description'])); + $this->assertTrue(!empty($json['provider'])); + $this->assertTrue(!empty($json['providerLink'])); + $this->assertTrue(!empty($json['mandate'])); + } + /** * Test updating a wallet (PUT /api/v4/wallets/:id) */ public function testUpdate(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); $wallet = $user->wallets()->first(); $discount = Discount::where('code', 'TEST')->first(); // Non-admin user $response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []); $response->assertStatus(403); // Admin user - setting a discount $post = ['discount' => $discount->id]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame($discount->discount, $json['discount']); $this->assertSame($discount->id, $json['discount_id']); $this->assertSame($discount->description, $json['discount_description']); $this->assertSame($discount->id, $wallet->fresh()->discount->id); // Admin user - removing a discount $post = ['discount' => null]; $response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('User wallet updated successfully.', $json['message']); $this->assertSame($wallet->id, $json['id']); $this->assertSame(null, $json['discount_id']); $this->assertTrue(empty($json['discount_description'])); $this->assertSame(null, $wallet->fresh()->discount); } } diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php new file mode 100644 index 00000000..7bda949f --- /dev/null +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -0,0 +1,325 @@ + 'mollie']); + + $john = $this->getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $john->setSetting('mollie_id', null); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $john->setSetting('mollie_id', null); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + + parent::tearDown(); + } + + /** + * Test creating/updating/deleting an outo-payment mandate + * + * @group mollie + */ + public function testMandates(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/payments/mandate"); + $response->assertStatus(401); + $response = $this->post("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->put("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->delete("api/v4/payments/mandate"); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + // Test creating a mandate (invalid input) + $post = []; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => 100, 'balance' => 'a']; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => -100, 'balance' => 0]; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // Test creating a mandate (valid input) + $post = ['amount' => 20.10, 'balance' => 0]; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + + // Test fetching the mandate information + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Credit Card', $json['method']); + $this->assertSame(true, $json['isPending']); + $this->assertSame(false, $json['isValid']); + + $mandate_id = $json['id']; + + // We would have to invoke a browser to accept the "first payment" to make + // the mandate validated/completed. Instead, we'll mock the mandate object. + $mollie_response = [ + 'resource' => 'mandate', + 'id' => $json['id'], + 'status' => 'valid', + 'method' => 'creditcard', + 'details' => [ + 'cardNumber' => '4242', + 'cardLabel' => 'Visa', + ], + 'customerId' => 'cst_GMfxGPt7Gj', + 'createdAt' => '2020-04-28T11:09:47+00:00', + ]; + + $responseStack = $this->mockMollie(); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); + $this->assertSame(false, $json['isPending']); + $this->assertSame(true, $json['isValid']); + + // Test updating mandate details (invalid input) + $post = []; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + $post = ['amount' => -100, 'balance' => 0]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // Test updating a mandate (valid input) + $post = ['amount' => 30.10, 'balance' => 1]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been updated.', $json['message']); + + $wallet = $user->wallets()->first(); + + $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); + $this->assertEquals(1, $wallet->getSetting('mandate_balance')); + + $this->unmockMollie(); + + // Delete mandate + $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been removed.', $json['message']); + + // Confirm with Mollie the mandate does not exist + $customer_id = $wallet->getSetting('mollie_id'); + $this->expectException(\Mollie\Api\Exceptions\ApiException::class); + $this->expectExceptionMessageMatches('/410: Gone/'); + $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); + + $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); + } + + /** + * Test creating a payment and receiving a status via webhook + * + * @group mollie + */ + public function testStoreAndWebhook(): void + { + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + $post = ['amount' => -1]; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + $post = ['amount' => '12.34']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + + $wallet = $user->wallets()->first(); + $payments = Payment::where('wallet_id', $wallet->id)->get(); + + $this->assertCount(1, $payments); + $payment = $payments[0]; + $this->assertSame(1234, $payment->amount); + $this->assertSame(\config('app.name') . ' Payment', $payment->description); + $this->assertSame('open', $payment->status); + $this->assertEquals(0, $wallet->balance); + + // Test the webhook + // Note: Webhook end-point does not require authentication + + $mollie_response = [ + "resource" => "payment", + "id" => $payment->id, + "status" => "paid", + // Status is not enough, paidAt is used to distinguish the state + "paidAt" => date('c'), + "mode" => "test", + ]; + + // We'll trigger the webhook with payment id and use mocking for + // a request to the Mollie payments API. We cannot force Mollie + // to make the payment status change. + $responseStack = $this->mockMollie(); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $post = ['id' => $payment->id]; + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Verify "paid -> open -> paid" scenario, assert that balance didn't change + $mollie_response['status'] = 'open'; + unset($mollie_response['paidAt']); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + $mollie_response['status'] = 'paid'; + $mollie_response['paidAt'] = date('c'); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + } + + /** + * Test automatic payment charges + * + * @group mollie + */ + public function testDirectCharge(): void + { + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + // Expect false result, as there's no mandate + $result = PaymentsController::directCharge($wallet, 1234); + $this->assertFalse($result); + + // Problem with this is we need to have a valid mandate + // And there's no easy way to confirm a created mandate. + // The only way seems to be to fire up Chrome on checkout page + // and do some actions with use of Dusk browser. + + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php new file mode 100644 index 00000000..d0510c7b --- /dev/null +++ b/src/tests/Feature/Controller/PaymentsStripeTest.php @@ -0,0 +1,274 @@ + 'stripe']); + + $john = $this->getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $john->setSetting('mollie_id', null); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $john->setSetting('mollie_id', null); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + + parent::tearDown(); + } + + /** + * Test creating/updating/deleting an outo-payment mandate + * + * @group stripe + */ + public function testMandates(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/payments/mandate"); + $response->assertStatus(401); + $response = $this->post("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->put("api/v4/payments/mandate", []); + $response->assertStatus(401); + $response = $this->delete("api/v4/payments/mandate"); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + // Test creating a mandate (invalid input) + $post = []; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => 100, 'balance' => 'a']; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); + + // Test creating a mandate (invalid input) + $post = ['amount' => -100, 'balance' => 0]; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // Test creating a mandate (valid input) + $post = ['amount' => 20.10, 'balance' => 0]; + $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^cs_test_|', $json['id']); + + // Test fetching the mandate information + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + + // We would have to invoke a browser to accept the "first payment" to make + // the mandate validated/completed. Instead, we'll mock the mandate object. + $setupIntent = '{ + "id": "AAA", + "object": "setup_intent", + "created": 123456789, + "payment_method": "pm_YYY", + "status": "succeeded", + "usage": "off_session" + }'; + + $paymentMethod = '{ + "id": "pm_YYY", + "object": "payment_method", + "card": { + "brand": "visa", + "country": "US", + "last4": "4242" + }, + "created": 123456789, + "type": "card" + }'; + + $client = $this->mockStripe(); + $client->addResponse($setupIntent); + $client->addResponse($paymentMethod); + + // As we do not use checkout page, we do not receive a webworker request + // I.e. we have to fake the mandate id + $wallet = $user->wallets()->first(); + $wallet->setSetting('stripe_mandate_id', 'AAA'); + + $response = $this->actingAs($user)->get("api/v4/payments/mandate"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertEquals(20.10, $json['amount']); + $this->assertEquals(0, $json['balance']); + $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); + $this->assertSame(false, $json['isPending']); + $this->assertSame(true, $json['isValid']); + + // Test updating mandate details (invalid input) + $post = []; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(2, $json['errors']); + $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); + $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); + + $post = ['amount' => -100, 'balance' => 0]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // Test updating a mandate (valid input) + $post = ['amount' => 30.10, 'balance' => 1]; + $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame('The auto-payment has been updated.', $json['message']); + + + $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); + $this->assertEquals(1, $wallet->getSetting('mandate_balance')); + + $this->unmockStripe(); + + // TODO: Delete mandate + } + + /** + * Test creating a payment and receiving a status via webhook + * + * @group stripe + */ + public function testStoreAndWebhook(): void + { + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + $post = ['amount' => -1]; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + $post = ['amount' => '12.34']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^cs_test_|', $json['id']); + + $wallet = $user->wallets()->first(); + $payments = Payment::where('wallet_id', $wallet->id)->get(); + + $this->assertCount(1, $payments); + $payment = $payments[0]; + $this->assertSame(1234, $payment->amount); + $this->assertSame(\config('app.name') . ' Payment', $payment->description); + $this->assertSame('open', $payment->status); + $this->assertEquals(0, $wallet->balance); + + // TODO: Test the webhook + } + + /** + * Test automatic payment charges + * + * @group stripe + */ + public function testDirectCharge(): void + { + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + // Expect false result, as there's no mandate + $result = PaymentsController::directCharge($wallet, 1234); + $this->assertFalse($result); + + // Problem with this is we need to have a valid mandate + // And there's no easy way to confirm a created mandate. + // The only way seems to be to fire up Chrome on checkout page + // and do some actions with use of Dusk browser. + + $this->markTestIncomplete(); + } +} diff --git a/src/tests/Feature/Controller/PaymentsTest.php b/src/tests/Feature/Controller/PaymentsTest.php deleted file mode 100644 index 09b1b0ab..00000000 --- a/src/tests/Feature/Controller/PaymentsTest.php +++ /dev/null @@ -1,156 +0,0 @@ -getTestUser('john@kolab.org'); - $wallet = $john->wallets()->first(); - $john->setSetting('mollie_id', null); - Payment::where('wallet_id', $wallet->id)->delete(); - Wallet::where('id', $wallet->id)->update(['balance' => 0]); - } - - /** - * {@inheritDoc} - */ - public function tearDown(): void - { - $john = $this->getTestUser('john@kolab.org'); - $wallet = $john->wallets()->first(); - $john->setSetting('mollie_id', null); - Payment::where('wallet_id', $wallet->id)->delete(); - Wallet::where('id', $wallet->id)->update(['balance' => 0]); - - parent::tearDown(); - } - - /** - * Test creating a payment and receiving a status via webhook) - * - * @group mollie - */ - public function testStoreAndWebhook(): void - { - // Unauth access not allowed - $response = $this->post("api/v4/payments", []); - $response->assertStatus(401); - - $user = $this->getTestUser('john@kolab.org'); - - $post = ['amount' => -1]; - $response = $this->actingAs($user)->post("api/v4/payments", $post); - $response->assertStatus(422); - - $json = $response->json(); - - $this->assertSame('error', $json['status']); - $this->assertCount(1, $json['errors']); - $this->assertSame('The amount must be at least 1.', $json['errors']['amount'][0]); - - $post = ['amount' => 1234]; - $response = $this->actingAs($user)->post("api/v4/payments", $post); - $response->assertStatus(200); - - $json = $response->json(); - - $this->assertSame('success', $json['status']); - $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); - - $wallet = $user->wallets()->first(); - $payments = Payment::where('wallet_id', $wallet->id)->get(); - - $this->assertCount(1, $payments); - $payment = $payments[0]; - $this->assertSame(1234, $payment->amount); - $this->assertSame('Kolab Now Payment', $payment->description); - $this->assertSame('open', $payment->status); - $this->assertEquals(0, $wallet->balance); - - // Test the webhook - // Note: Webhook end-point does not require authentication - - $mollie_response = [ - "resource" => "payment", - "id" => $payment->id, - "status" => "paid", - // Status is not enough, paidAt is used to distinguish the state - "paidAt" => date('c'), - "mode" => "test", -/* - "createdAt" => "2018-03-20T13:13:37+00:00", - "amount" => { - "value" => "10.00", - "currency" => "EUR" - }, - "description" => "Order #12345", - "method" => null, - "metadata" => { - "order_id" => "12345" - }, - "isCancelable" => false, - "locale" => "nl_NL", - "restrictPaymentMethodsToCountry" => "NL", - "expiresAt" => "2018-03-20T13:28:37+00:00", - "details" => null, - "profileId" => "pfl_QkEhN94Ba", - "sequenceType" => "oneoff", - "redirectUrl" => "https://webshop.example.org/order/12345/", - "webhookUrl" => "https://webshop.example.org/payments/webhook/", -*/ - ]; - - // We'll trigger the webhook with payment id and use mocking for - // a request to the Mollie payments API. We cannot force Mollie - // to make the payment status change. - $responseStack = $this->mockMollie(); - $responseStack->append(new Response(200, [], json_encode($mollie_response))); - - $post = ['id' => $payment->id]; - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $this->assertSame('paid', $payment->fresh()->status); - $this->assertEquals(1234, $wallet->fresh()->balance); - - // Verify "paid -> open -> paid" scenario, assert that balance didn't change - $mollie_response['status'] = 'open'; - unset($mollie_response['paidAt']); - $responseStack->append(new Response(200, [], json_encode($mollie_response))); - - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $this->assertSame('paid', $payment->fresh()->status); - $this->assertEquals(1234, $wallet->fresh()->balance); - - $mollie_response['status'] = 'paid'; - $mollie_response['paidAt'] = date('c'); - $responseStack->append(new Response(200, [], json_encode($mollie_response))); - - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $this->assertSame('paid', $payment->fresh()->status); - $this->assertEquals(1234, $wallet->fresh()->balance); - } - - public function testDirectCharge(): void - { - $this->markTestIncomplete(); - } -} diff --git a/src/tests/Feature/Controller/UsersTest.php b/src/tests/Feature/Controller/UsersTest.php index f2157997..d7e5a207 100644 --- a/src/tests/Feature/Controller/UsersTest.php +++ b/src/tests/Feature/Controller/UsersTest.php @@ -1,846 +1,851 @@ 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->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); + $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); } /** * {@inheritDoc} */ public function tearDown(): void { $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->deleteTestDomain('userscontroller.com'); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->discount()->dissociate(); + $wallet->settings()->whereIn('key', ['mollie_id', 'stripe_id'])->delete(); $wallet->save(); $user->status |= User::STATUS_IMAP_READY; $user->save(); parent::tearDown(); } /** * Test user deleting (DELETE /api/v4/users/) */ 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/) */ 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->assertCount(0, $json); $response = $this->actingAs($john)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json[0]); $this->assertArrayHasKey('isSuspended', $json[0]); $this->assertArrayHasKey('isActive', $json[0]); $this->assertArrayHasKey('isLdapReady', $json[0]); $this->assertArrayHasKey('isImapReady', $json[0]); $response = $this->actingAs($ned)->get("/api/v4/users"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(4, $json); $this->assertSame($jack->email, $json[0]['email']); $this->assertSame($joe->email, $json[1]['email']); $this->assertSame($john->email, $json[2]['email']); $this->assertSame($ned->email, $json[3]['email']); } /** * Test fetching user data/profile (GET /api/v4/users/) */ 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(is_array($json['aliases'])); $this->assertSame([], $json['skus']); // Values below are tested by Unit tests $this->assertArrayHasKey('isDeleted', $json); $this->assertArrayHasKey('isSuspended', $json); $this->assertArrayHasKey('isActive', $json); $this->assertArrayHasKey('isLdapReady', $json); $this->assertArrayHasKey('isImapReady', $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); $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::where('title', 'storage')->first(); $groupware_sku = Sku::where('title', 'groupware')->first(); $mailbox_sku = Sku::where('title', 'mailbox')->first(); $secondfactor_sku = Sku::where('title', '2fa')->first(); $this->assertCount(5, $json['skus']); $this->assertSame(2, $json['skus'][$storage_sku->id]['count']); $this->assertSame(1, $json['skus'][$groupware_sku->id]['count']); $this->assertSame(1, $json['skus'][$mailbox_sku->id]['count']); $this->assertSame(1, $json['skus'][$secondfactor_sku->id]['count']); } /** * Test fetching user status (GET /api/v4/users//status) * and forcing setup process update (?refresh=1) * * @group imap * @group dns */ public function testStatus(): void { $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->save(); // Get user status $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse($json['isImapReady']); $this->assertFalse($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(false, $json['process'][2]['state']); $this->assertTrue(empty($json['status'])); $this->assertTrue(empty($json['message'])); // Now "reboot" the process and verify the user in imap syncronously $response = $this->actingAs($john)->get("/api/v4/users/{$john->id}/status?refresh=1"); $response->assertStatus(200); $json = $response->json(); $this->assertTrue($json['isImapReady']); - $this->assertFalse($json['isReady']); + $this->assertTrue($json['isReady']); $this->assertCount(7, $json['process']); $this->assertSame('user-imap-ready', $json['process'][2]['label']); $this->assertSame(true, $json['process'][2]['state']); - $this->assertSame('domain-confirmed', $json['process'][6]['label']); - $this->assertSame(false, $json['process'][6]['state']); - $this->assertSame('error', $json['status']); - $this->assertSame('Failed to verify an ownership of a domain.', $json['message']); - - // TODO: Test completing all process steps + $this->assertSame('success', $json['status']); + $this->assertSame('Setup process finished successfully.', $json['message']); } /** * 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['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $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']); $this->assertSame('running', $result['processState']); $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['isReady']); $this->assertCount(3, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $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('done', $result['processState']); $domain->status |= Domain::STATUS_VERIFIED; $domain->type = Domain::TYPE_EXTERNAL; $domain->save(); $result = UsersController::statusInfo($user); $this->assertFalse($result['isReady']); $this->assertCount(7, $result['process']); $this->assertSame('user-new', $result['process'][0]['label']); $this->assertSame(true, $result['process'][0]['state']); $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']); } /** * Test user creation (POST /api/v4/users) */ public function testStore(): void { $jack = $this->getTestUser('jack@kolab.org'); $john = $this->getTestUser('john@kolab.org'); // 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' => 'simple', 'password_confirmation' => 'simple', '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::where('title', 'kolab')->first(); $package_domain = \App\Package::where('title', 'domain-hosting')->first(); $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', 'first_name' => 'John2', 'last_name' => 'Doe2', 'email' => 'john2.doe2@kolab.org', 'organization' => 'TestOrg', 'aliases' => ['useralias1@kolab.org', 'useralias2@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 full and valid data $post['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 = 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')); $aliases = $user->aliases()->orderBy('alias')->get(); $this->assertCount(2, $aliases); $this->assertSame('useralias1@kolab.org', $aliases[0]->alias); $this->assertSame('useralias2@kolab.org', $aliases[1]->alias); // Assert the new user entitlements $this->assertUserEntitlements($user, ['groupware', 'mailbox', '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); // Test acting as account controller (not owner) /* // FIXME: How do we know to which wallet the new user should be assigned to? $this->deleteTestUser('john2.doe2@kolab.org'); $response = $this->actingAs($ned)->post("/api/v4/users", $post); $json = $response->json(); $response->assertStatus(200); $this->assertSame('success', $json['status']); */ $this->markTestIncomplete(); } /** * Test user update (PUT /api/v4/users/) */ public function testUpdate(): void { $userA = $this->getTestUser('UsersControllerTest1@userscontroller.com'); $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->assertCount(2, $json); // Test some invalid data $post = ['password' => '12345678', '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('The currency must be 3 characters.', $json['errors']['currency'][0]); // Test full profile update including password $post = [ 'password' => 'simple', 'password_confirmation' => 'simple', '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->assertCount(2, $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->assertCount(2, $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 setting an alias to other user's domain // and missing password confirmation $post = [ 'password' => 'simple123', 'aliases' => ['useralias2@' . \config('app.domain'), 'useralias1@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(1, $json['errors']['aliases']); $this->assertSame("The specified domain is not available.", $json['errors']['aliases'][1]); $this->assertSame("The password confirmation does not match.", $json['errors']['password'][0]); // Test authorized update of other user $response = $this->actingAs($ned)->get("/api/v4/users/{$jack->id}", []); $response->assertStatus(200); // 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::where('title', 'domain-hosting')->first(); $package_kolab = Package::where('title', 'kolab')->first(); $package_lite = Package::where('title', 'lite')->first(); $sku_mailbox = Sku::where('title', 'mailbox')->first(); $sku_storage = Sku::where('title', 'storage')->first(); $sku_groupware = Sku::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 => 3, $sku_groupware->id => 1, ], ]; $response = $this->actingAs($owner)->put("/api/v4/users/{$user->id}", $post); $response->assertStatus(200); $storage_cost = $user->entitlements() ->where('sku_id', $sku_storage->id) ->orderBy('cost') ->pluck('cost')->all(); $this->assertUserEntitlements($user, ['groupware', 'mailbox', 'storage', 'storage', 'storage']); $this->assertSame([0, 0, 25], $storage_cost); } /** * Test UsersController::updateEntitlements() */ public function testUpdateEntitlements(): void { // TODO: Test more cases of entitlements update $this->markTestIncomplete(); } /** * Test user data response used in show and info actions */ public function testUserResponse(): void { + $provider = \config('payment_provider') ?: 'mollie'; $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->id, $result['id']); $this->assertEquals($user->email, $result['email']); $this->assertEquals($user->status, $result['status']); $this->assertTrue(is_array($result['statusInfo'])); $this->assertTrue(is_array($result['aliases'])); $this->assertCount(1, $result['aliases']); $this->assertSame('john.doe@kolab.org', $result['aliases'][0]); $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']); $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']); // Test discount in a response $discount = Discount::where('code', 'TEST')->first(); $wallet->discount()->associate($discount); $wallet->save(); + $mod_provider = $provider == 'mollie' ? 'stripe' : 'mollie'; + $wallet->setSetting($mod_provider . '_id', 123); $user->refresh(); $result = $this->invokeMethod(new UsersController(), 'userResponse', [$user]); $this->assertEquals($user->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']); } /** * List of alias validation cases for testValidateEmail() * * @return array Arguments for testValidateEmail() */ public function dataValidateEmail(): array { $this->refreshApplication(); $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'); return [ // Invalid format ["$domain", $john, true, 'The specified alias is invalid.'], [".@$domain", $john, true, 'The specified alias is invalid.'], ["test123456@localhost", $john, true, 'The specified domain is invalid.'], ["test123456@unknown-domain.org", $john, true, 'The specified domain is invalid.'], ["$domain", $john, false, 'The specified email is invalid.'], [".@$domain", $john, false, 'The specified email is invalid.'], // forbidden local part on public domains ["admin@$domain", $john, true, 'The specified alias is not available.'], ["administrator@$domain", $john, true, 'The specified alias is not available.'], // forbidden (other user's domain) ["testtest@kolab.org", $user, true, 'The specified domain is not available.'], // existing alias of other user ["jack.daniels@kolab.org", $john, true, 'The specified alias is not available.'], // existing user ["jack@kolab.org", $john, true, 'The specified alias is not available.'], // valid (user domain) ["admin@kolab.org", $john, true, null], // valid (public domain) ["test.test@$domain", $john, true, null], ]; } /** * 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? * * @dataProvider dataValidateEmail */ public function testValidateEmail($alias, $user, $is_alias, $expected_result): void { $result = $this->invokeMethod(new \App\Utils(), 'validateEmail', [$alias, $user, $is_alias]); $this->assertSame($expected_result, $result); } } diff --git a/src/tests/MollieMocksTrait.php b/src/tests/MollieMocksTrait.php index 49cdd49c..d28bef13 100644 --- a/src/tests/MollieMocksTrait.php +++ b/src/tests/MollieMocksTrait.php @@ -1,51 +1,57 @@ push( Middleware::history($this->mollieRequestHistory) ); $guzzle = new Client(['handler' => $handler]); - $this->app->forgetInstance('mollie.api.client'); - $this->app->forgetInstance('mollie.api'); - $this->app->forgetInstance('mollie'); + $this->app->forgetInstance('mollie.api.client'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie.api'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie'); // @phpstan-ignore-line $this->app->singleton('mollie.api.client', function () use ($guzzle) { return new MollieApiClient($guzzle); }); return $mockHandler; } public function unmockMollie() { - $this->app->forgetInstance('mollie.api.client'); - $this->app->forgetInstance('mollie.api'); - $this->app->forgetInstance('mollie'); + $this->app->forgetInstance('mollie.api.client'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie.api'); // @phpstan-ignore-line + $this->app->forgetInstance('mollie'); // @phpstan-ignore-line + + $guzzle = new Client(); + + $this->app->singleton('mollie.api.client', function () use ($guzzle) { + return new MollieApiClient($guzzle); + }); } } diff --git a/src/tests/StripeMockClient.php b/src/tests/StripeMockClient.php new file mode 100644 index 00000000..9afefb3f --- /dev/null +++ b/src/tests/StripeMockClient.php @@ -0,0 +1,22 @@ +responses); + + return $response; + } + + public function addResponse($body, $code = 200, $headers = []) + { + $this->responses[] = [$body, $code, $headers]; + } +} diff --git a/src/tests/StripeMocksTrait.php b/src/tests/StripeMocksTrait.php new file mode 100644 index 00000000..ecfa2f4e --- /dev/null +++ b/src/tests/StripeMocksTrait.php @@ -0,0 +1,27 @@ +created_at = $targetDate; $entitlement->updated_at = $targetDate; $entitlement->save(); } } /** * Set baseURL to the admin UI location */ protected static function useAdminUrl(): void { // This will set base URL for all tests in a file. // If we wanted to access both user and admin in one test // we can also just call post/get/whatever with full url \config(['app.url' => str_replace('//', '//admin.', \config('app.url'))]); - url()->forceRootUrl(config('app.url')); + url()->forceRootUrl(config('app.url')); // @phpstan-ignore-line } }