diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php index 8f2479e2..04326e2e 100644 --- a/src/app/Documents/Receipt.php +++ b/src/app/Documents/Receipt.php @@ -1,277 +1,277 @@ wallet = $wallet; $this->year = $year; $this->month = $month; } /** * Render the mail template with fake data * * @param string $type Output format ('html' or 'pdf') * * @return string HTML or PDF output */ public static function fakeRender(string $type = 'html'): string { $wallet = new Wallet(['currency' => 'CHF']); $wallet->id = \App\Utils::uuidStr(); $wallet->owner = new User(['id' => 123456789]); $receipt = new self($wallet, date('Y'), date('n')); self::$fakeMode = true; if ($type == 'pdf') { return $receipt->pdfOutput(); } elseif ($type !== 'html') { throw new \Exception("Unsupported output format"); } return $receipt->htmlOutput(); } /** * Render the receipt in HTML format. * * @return string HTML content */ public function htmlOutput(): string { return $this->build()->render(); } /** * Render the receipt in PDF format. * * @return string PDF content */ public function pdfOutput(): string { // Parse ther HTML template $html = $this->build()->render(); // Link fonts from public/fonts to storage/fonts so DomPdf can find them if (!is_link(storage_path('fonts/Roboto-Regular.ttf'))) { symlink( public_path('fonts/Roboto-Regular.ttf'), storage_path('fonts/Roboto-Regular.ttf') ); symlink( public_path('fonts/Roboto-Bold.ttf'), storage_path('fonts/Roboto-Bold.ttf') ); } // Fix font and image paths $html = str_replace('url(/fonts/', 'url(fonts/', $html); $html = str_replace('src="/', 'src="', $html); // TODO: The output file is about ~200KB, we could probably slim it down // by using separate font files with small subset of languages when // there are no Unicode characters used, e.g. only ASCII or Latin. // Load PDF generator $pdf = Pdf::loadHTML($html)->setPaper('a4', 'portrait'); return $pdf->output(); } /** * Build the document * * @return \Illuminate\View\View The template object */ protected function build() { $appName = \config('app.name'); $start = Carbon::create($this->year, $this->month, 1, 0, 0, 0); $end = $start->copy()->endOfMonth(); $month = \trans('documents.month' . intval($this->month)); $title = \trans('documents.receipt-title', ['year' => $this->year, 'month' => $month]); $company = $this->companyData(); if (self::$fakeMode) { $customer = [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, 'customer' => 'Freddie Krüger
7252 Westminster Lane
Forest Hills, NY 11375', ]; $items = collect([ (object) [ 'amount' => 1234, 'updated_at' => $start->copy()->next(Carbon::MONDAY), ], (object) [ 'amount' => 10000, 'updated_at' => $start->copy()->next()->next(), ], (object) [ 'amount' => 1234, 'updated_at' => $start->copy()->next()->next()->next(Carbon::MONDAY), ], (object) [ 'amount' => 99, 'updated_at' => $start->copy()->next()->next()->next(), ], ]); $items = $items->map(function ($payment) { $payment->vatRate = new \App\VatRate(); $payment->vatRate->rate = 7.7; $payment->credit_amount = $payment->amount + round($payment->amount * $payment->vatRate->rate / 100); return $payment; }); } else { $customer = $this->customerData(); $items = $this->wallet->payments() ->where('status', Payment::STATUS_PAID) ->where('updated_at', '>=', $start) ->where('updated_at', '<', $end) ->where('amount', '<>', 0) ->orderBy('updated_at') ->get(); } $vatRate = 0; $totalVat = 0; $total = 0; // excluding VAT $items = $items->map(function ($item) use (&$total, &$totalVat, &$vatRate, $appName) { $amount = $item->amount; if ($item->vatRate && $item->vatRate->rate > 0) { $vat = round($item->credit_amount * $item->vatRate->rate / 100); $amount -= $vat; $totalVat += $vat; $vatRate = $item->vatRate->rate; // TODO: Multiple rates } $total += $amount; $type = $item->type ?? null; if ($type == Payment::TYPE_REFUND) { $description = \trans('documents.receipt-refund'); } elseif ($type == Payment::TYPE_CHARGEBACK) { $description = \trans('documents.receipt-chargeback'); } else { $description = \trans('documents.receipt-item-desc', ['site' => $appName]); } return [ 'amount' => $this->wallet->money($amount), 'description' => $description, 'date' => $item->updated_at->toDateString(), ]; }); // Load the template $view = view('documents.receipt') ->with([ 'site' => $appName, 'title' => $title, 'company' => $company, 'customer' => $customer, 'items' => $items, 'subTotal' => $this->wallet->money($total), 'total' => $this->wallet->money($total + $totalVat), 'totalVat' => $this->wallet->money($totalVat), - 'vatRate' => preg_replace('/([.,]00|0|[.,])$/', '', sprintf('%.2f', $vatRate)), + 'vatRate' => preg_replace('/(\.00|0|\.)$/', '', sprintf('%.2F', $vatRate)), 'vat' => $vatRate > 0, ]); return $view; } /** * Prepare customer data for the template * * @return array Customer data for the template */ protected function customerData(): array { $user = $this->wallet->owner; $name = $user->name(); $settings = $user->getSettings(['organization', 'billing_address']); $customer = trim(($settings['organization'] ?: $name) . "\n" . $settings['billing_address']); $customer = str_replace("\n", '
', htmlentities($customer)); return [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, 'customer' => $customer, ]; } /** * Prepare company data for the template * * @return array Company data for the template */ protected function companyData(): array { $header = \config('app.company.name') . "\n" . \config('app.company.address'); $header = str_replace("\n", '
', htmlentities($header)); $footerLineLength = 110; $footer = \config('app.company.details'); $contact = \config('app.company.email'); $logo = \config('app.company.logo'); $theme = \config('app.theme'); if ($contact) { $length = strlen($footer) + strlen($contact) + 3; $contact = htmlentities($contact); $footer .= ($length > $footerLineLength ? "\n" : ' | ') . sprintf('%s', $contact, $contact); } if ($logo && strpos($logo, '/') === false) { $logo = "/themes/$theme/images/$logo"; } return [ 'logo' => $logo ? "" : '', 'header' => $header, 'footer' => $footer, ]; } } diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php index b1abca6b..2c287a17 100644 --- a/src/app/Http/Controllers/API/SignupController.php +++ b/src/app/Http/Controllers/API/SignupController.php @@ -1,617 +1,617 @@ orderBy('months')->orderByDesc('title')->get() ->map(function ($plan) { $button = self::trans("app.planbutton-{$plan->title}"); if (strpos($button, 'app.planbutton') !== false) { $button = self::trans('app.planbutton', ['plan' => $plan->name]); } return [ 'title' => $plan->title, 'name' => $plan->name, 'button' => $button, 'description' => $plan->description, 'mode' => $plan->mode ?: Plan::MODE_EMAIL, 'isDomain' => $plan->hasDomain(), ]; }) ->all(); return response()->json(['status' => 'success', 'plans' => $plans]); } /** * Returns list of public domains for signup. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function domains(Request $request) { return response()->json(['status' => 'success', 'domains' => Domain::getPublicDomains()]); } /** * Starts signup process. * * Verifies user name and email/phone, sends verification email/sms message. * Returns the verification code. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function init(Request $request) { $rules = [ 'first_name' => 'max:128', 'last_name' => 'max:128', 'voucher' => 'max:32', ]; $plan = $this->getPlan(); if ($plan->mode == Plan::MODE_TOKEN) { $rules['token'] = ['required', 'string', new SignupToken()]; } else { $rules['email'] = ['required', 'string', new SignupExternalEmail()]; } // Check required fields, validate input $v = Validator::make($request->all(), $rules); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()->toArray()], 422); } // Generate the verification code $code = SignupCode::create([ 'email' => $plan->mode == Plan::MODE_TOKEN ? $request->token : $request->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, 'plan' => $plan->title, 'voucher' => $request->voucher, ]); $response = [ 'status' => 'success', 'code' => $code->code, 'mode' => $plan->mode ?: 'email', ]; if ($plan->mode == Plan::MODE_TOKEN) { // Token verification, jump to the last step $has_domain = $plan->hasDomain(); $response['short_code'] = $code->short_code; $response['is_domain'] = $has_domain; $response['domains'] = $has_domain ? [] : Domain::getPublicDomains(); } else { // External email verification, send an email message SignupVerificationEmail::dispatch($code); } return response()->json($response); } /** * Returns signup invitation information. * * @param string $id Signup invitation identifier * * @return \Illuminate\Http\JsonResponse|void */ public function invitation($id) { $invitation = SignupInvitation::withEnvTenantContext()->find($id); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } $has_domain = $this->getPlan()->hasDomain(); $result = [ 'id' => $id, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]; return response()->json($result); } /** * Validation of the verification code. * * @param \Illuminate\Http\Request $request HTTP request * @param bool $update Update the signup code record * * @return \Illuminate\Http\JsonResponse JSON response */ public function verify(Request $request, $update = true) { // Validate the request args $v = Validator::make( $request->all(), [ 'code' => 'required', 'short_code' => 'required', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } // Validate the verification code $code = SignupCode::find($request->code); if ( empty($code) || $code->isExpired() || Str::upper($request->short_code) !== Str::upper($code->short_code) ) { $errors = ['short_code' => "The code is invalid or expired."]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } // For signup last-step mode remember the code object, so we can delete it // with single SQL query (->delete()) instead of two $request->code = $code; if ($update) { $code->verify_ip_address = $request->ip(); $code->save(); } $has_domain = $this->getPlan()->hasDomain(); // Return user name and email/phone/voucher from the codes database, // domains list for selection and "plan type" flag return response()->json([ 'status' => 'success', 'email' => $code->email, 'first_name' => $code->first_name, 'last_name' => $code->last_name, 'voucher' => $code->voucher, 'is_domain' => $has_domain, 'domains' => $has_domain ? [] : Domain::getPublicDomains(), ]); } /** * Validates the input to the final signup request. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signupValidate(Request $request) { // Validate input $v = Validator::make( $request->all(), [ 'login' => 'required|min:2', 'password' => ['required', 'confirmed', new Password()], 'domain' => 'required', 'voucher' => 'max:32', ] ); if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $settings = []; // Plan parameter is required/allowed in mandate mode if (!empty($request->plan) && empty($request->code) && empty($request->invitation)) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); if (!$plan || $plan->mode != Plan::MODE_MANDATE) { $msg = self::trans('validation.exists', ['attribute' => 'plan']); return response()->json(['status' => 'error', 'errors' => ['plan' => $msg]], 422); } } elseif ($request->invitation) { // Signup via invitation $invitation = SignupInvitation::withEnvTenantContext()->find($request->invitation); if (empty($invitation) || $invitation->isCompleted()) { return $this->errorResponse(404); } // Check required fields $v = Validator::make( $request->all(), [ 'first_name' => 'max:128', 'last_name' => 'max:128', ] ); $errors = $v->fails() ? $v->errors()->toArray() : []; if (!empty($errors)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $settings = [ 'external_email' => $invitation->email, 'first_name' => $request->first_name, 'last_name' => $request->last_name, ]; } else { // Validate verification codes (again) $v = $this->verify($request, false); if ($v->status() !== 200) { return $v; } $plan = $this->getPlan(); // Get user name/email from the verification code database $code_data = $v->getData(); $settings = [ 'first_name' => $code_data->first_name, 'last_name' => $code_data->last_name, ]; if ($plan->mode == Plan::MODE_TOKEN) { $settings['signup_token'] = $code_data->email; } else { $settings['external_email'] = $code_data->email; } } // Find the voucher discount if ($request->voucher) { $discount = Discount::where('code', \strtoupper($request->voucher)) ->where('active', true)->first(); if (!$discount) { $errors = ['voucher' => self::trans('validation.voucherinvalid')]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } } if (empty($plan)) { $plan = $this->getPlan(); } $is_domain = $plan->hasDomain(); // Validate login if ($errors = self::validateLogin($request->login, $request->domain, $is_domain)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } // Set some properties for signup() method $request->settings = $settings; $request->plan = $plan; $request->discount = $discount ?? null; $request->invitation = $invitation ?? null; $result = []; if ($plan->mode == Plan::MODE_MANDATE) { $result = $this->mandateForPlan($plan, $request->discount); } return response()->json($result + ['status' => 'success']); } /** * Finishes the signup process by creating the user account. * * @param \Illuminate\Http\Request $request HTTP request * * @return \Illuminate\Http\JsonResponse JSON response */ public function signup(Request $request) { $v = $this->signupValidate($request); if ($v->status() !== 200) { return $v; } $is_domain = $request->plan->hasDomain(); // We allow only ASCII, so we can safely lower-case the email address $login = Str::lower($request->login); $domain_name = Str::lower($request->domain); $domain = null; $user_status = User::STATUS_RESTRICTED; if ( $request->discount && $request->discount->discount == 100 && $request->plan->mode == Plan::MODE_MANDATE ) { $user_status = User::STATUS_ACTIVE; } DB::beginTransaction(); // Create domain record if ($is_domain) { $domain = Domain::create([ 'namespace' => $domain_name, 'type' => Domain::TYPE_EXTERNAL, ]); } // Create user record $user = User::create([ 'email' => $login . '@' . $domain_name, 'password' => $request->password, 'status' => $user_status, ]); if ($request->discount) { $wallet = $user->wallets()->first(); $wallet->discount()->associate($request->discount); $wallet->save(); } $user->assignPlan($request->plan, $domain); // Save the external email and plan in user settings $user->setSettings($request->settings); // Update the invitation if ($request->invitation) { $request->invitation->status = SignupInvitation::STATUS_COMPLETED; $request->invitation->user_id = $user->id; $request->invitation->save(); } // Soft-delete the verification code, and store some more info with it if ($request->code) { $request->code->user_id = $user->id; $request->code->submit_ip_address = $request->ip(); $request->code->deleted_at = \now(); $request->code->timestamps = false; $request->code->save(); } DB::commit(); $response = AuthController::logonResponse($user, $request->password); if ($request->plan->mode == Plan::MODE_MANDATE) { $data = $response->getData(true); $data['checkout'] = $this->mandateForPlan($request->plan, $request->discount, $user); $response->setData($data); } return $response; } /** * Collects some content to display to the user before redirect to a checkout page. * Optionally creates a recurrent payment mandate for specified user/plan. */ protected function mandateForPlan(Plan $plan, Discount $discount = null, User $user = null): array { $result = []; $min = \App\Payment::MIN_AMOUNT; $planCost = $cost = $plan->cost(); $disc = 0; if ($discount) { // Free accounts don't need the auto-payment mandate // Note: This means the voucher code is the only point of user verification if ($discount->discount == 100) { return [ 'content' => self::trans('app.signup-account-free'), 'cost' => 0, ]; } $planCost = (int) ($planCost * (100 - $discount->discount) / 100); $disc = $cost - $planCost; } if ($planCost > $min) { $min = $planCost; } if ($user) { $wallet = $user->wallets()->first(); $wallet->setSettings([ - 'mandate_amount' => sprintf('%.2f', round($min / 100, 2)), + 'mandate_amount' => sprintf('%.2F', round($min / 100, 2)), 'mandate_balance' => 0, ]); $mandate = [ 'currency' => $wallet->currency, 'description' => \App\Tenant::getConfig($user->tenant_id, 'app.name') . ' ' . self::trans('app.mandate-description-suffix'), 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'redirectUrl' => Utils::serviceUrl('/payment/status', $user->tenant_id), ]; $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); } $country = Utils::countryForRequest(); $period = $plan->months == 12 ? 'yearly' : 'monthly'; $currency = \config('app.currency'); $rate = VatRate::where('country', $country) ->where('start', '<=', now()->format('Y-m-d h:i:s')) ->orderByDesc('start') ->limit(1) ->first(); $summary = '' . '' . self::trans("app.signup-subscription-{$period}") . '' . '' . Utils::money($cost, $currency) . '' . ''; if ($discount) { $summary .= '' . '' . self::trans('app.discount-code', ['code' => $discount->code]) . '' . '' . Utils::money(-$disc, $currency) . '' . ''; } $summary .= '' . '' . '' . self::trans('app.total') . '' . '' . Utils::money($planCost, $currency) . '' . ''; if ($rate && $rate->rate > 0) { // TODO: app.vat.mode $vat = round($planCost * $rate->rate / 100); $content = self::trans('app.vat-incl', [ 'rate' => Utils::percent($rate->rate), 'cost' => Utils::money($planCost - $vat, $currency), 'vat' => Utils::money($vat, $currency), ]); $summary .= '*' . $content . ''; } $trialEnd = $plan->free_months ? now()->copy()->addMonthsWithoutOverflow($plan->free_months) : now(); $params = [ 'cost' => Utils::money($planCost, $currency), 'date' => $trialEnd->toDateString(), ]; $result['title'] = self::trans("app.signup-plan-{$period}"); $result['content'] = self::trans('app.signup-account-mandate', $params); $result['summary'] = '' . $summary . '
'; $result['cost'] = $planCost; return $result; } /** * Returns plan for the signup process * * @returns \App\Plan Plan object selected for current signup process */ protected function getPlan() { $request = request(); if (!$request->plan || !$request->plan instanceof Plan) { // Get the plan if specified and exists... if (($request->code instanceof SignupCode) && $request->code->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->code->plan)->first(); } elseif ($request->plan) { $plan = Plan::withEnvTenantContext()->where('title', $request->plan)->first(); } // ...otherwise use the default plan if (empty($plan)) { // TODO: Get default plan title from config $plan = Plan::withEnvTenantContext()->where('title', 'individual')->first(); } $request->plan = $plan; } return $request->plan; } /** * Login (kolab identity) validation * * @param string $login Login (local part of an email address) * @param string $domain Domain name * @param bool $external Enables additional checks for domain part * * @return array Error messages on validation error */ protected static function validateLogin($login, $domain, $external = false): ?array { // Validate login part alone $v = Validator::make( ['login' => $login], ['login' => ['required', 'string', new UserEmailLocal($external)]] ); if ($v->fails()) { return ['login' => $v->errors()->toArray()['login'][0]]; } $domains = $external ? null : Domain::getPublicDomains(); // Validate the domain $v = Validator::make( ['domain' => $domain], ['domain' => ['required', 'string', new UserEmailDomain($domains)]] ); if ($v->fails()) { return ['domain' => $v->errors()->toArray()['domain'][0]]; } $domain = Str::lower($domain); // Check if domain is already registered with us if ($external) { if (Domain::withTrashed()->where('namespace', $domain)->exists()) { return ['domain' => self::trans('validation.domainexists')]; } } // Check if user with specified login already exists $email = $login . '@' . $domain; if (User::emailExists($email) || User::aliasExists($email) || \App\Group::emailExists($email)) { return ['login' => self::trans('validation.loginexists')]; } return null; } } diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php index db230951..f4242102 100644 --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -1,514 +1,514 @@ errorResponse(404); } $method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart))); if (!in_array($chart, $this->charts) || !method_exists($this, $method)) { return $this->errorResponse(404); } $result = $this->{$method}(); return response()->json($result); } /** * Get discounts chart */ protected function chartDiscounts(): array { $discounts = DB::table('wallets') ->selectRaw("discount, count(discount_id) as cnt") ->join('discounts', 'discounts.id', '=', 'wallets.discount_id') ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNull('users.deleted_at') ->groupBy('discounts.discount'); $addTenantScope = function ($builder, $tenantId) { return $builder->where('users.tenant_id', $tenantId); }; $discounts = $this->applyTenantScope($discounts, $addTenantScope) ->pluck('cnt', 'discount')->all(); $labels = array_keys($discounts); $discounts = array_values($discounts); // $labels = [10, 25, 30, 100]; // $discounts = [100, 120, 30, 50]; $labels = array_map(function ($item) { return $item . '%'; }, $labels); return $this->donutChart(self::trans('app.chart-discounts'), $labels, $discounts); } /** * Get income chart */ protected function chartIncome(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); // FIXME: We're using wallets.currency instead of payments.currency and payments.currency_amount // as I believe this way we have more precise amounts for this use-case (and default currency) $query = DB::table('payments') ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(credit_amount) as amount, wallets.currency") ->join('wallets', 'wallets.id', '=', 'wallet_id') ->where('updated_at', '>=', $start->toDateString()) ->where('status', Payment::STATUS_PAID) ->whereIn('type', [Payment::TYPE_ONEOFF, Payment::TYPE_RECURRING]) ->groupByRaw('period, wallets.currency'); $addTenantScope = function ($builder, $tenantId) { $where = sprintf( '`wallets`.`user_id` IN (select `id` from `users` where `tenant_id` = %d)', $tenantId ); return $builder->whereRaw($where); }; $currency = $this->currency(); $payments = []; $this->applyTenantScope($query, $addTenantScope) ->get() ->each(function ($record) use (&$payments, $currency) { $amount = $record->amount; if ($record->currency != $currency) { $amount = intval(round($amount * \App\Utils::exchangeRate($record->currency, $currency))); } if (isset($payments[$record->period])) { $payments[$record->period] += $amount / 100; } else { $payments[$record->period] = $amount / 100; } }); // TODO: exclude refunds/chargebacks $empty = array_fill_keys($labels, 0); $payments = array_values(array_merge($empty, $payments)); // $payments = [1000, 1200.25, 3000, 1897.50, 2000, 1900, 2134, 3330]; $avg = collect($payments)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ 'title' => self::trans('app.chart-income', ['currency' => $currency]), 'type' => 'bar', 'colors' => [self::COLOR_BLUE], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Payments', 'values' => $payments ] ], 'yMarkers' => [ [ - 'label' => sprintf('average = %.2f', $avg), + 'label' => sprintf('average = %.2F', $avg), 'value' => $avg, 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get payers chart */ protected function chartPayers(): array { list($labels, $stats) = $this->getCollectedStats(self::TYPE_PAYERS, 54, fn($v) => intval($v)); // See https://frappe.io/charts/docs for format/options description return [ 'title' => self::trans('app.chart-payers'), 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $stats ] ] ] ]; } /** * Get created/deleted users chart */ protected function chartUsers(): array { $weeks = 8; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) ->groupByRaw('1'); $created = $this->applyTenantScope($created)->get(); $deleted = $this->applyTenantScope($deleted)->get(); $empty = array_fill_keys($labels, 0); $created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all())); $deleted = array_values(array_merge($empty, $deleted->pluck('cnt', 'period')->all())); // $created = [5, 2, 4, 2, 0, 5, 2, 4]; // $deleted = [1, 2, 3, 1, 2, 1, 2, 3]; $avg = collect($created)->slice(0, count($labels) - 1)->avg(); // See https://frappe.io/charts/docs for format/options description return [ 'title' => self::trans('app.chart-users'), 'type' => 'bar', // Required to fix https://github.com/frappe/charts/issues/294 'colors' => [self::COLOR_GREEN, self::COLOR_RED], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ 'name' => self::trans('app.chart-created'), 'chartType' => 'bar', 'values' => $created ], [ 'name' => self::trans('app.chart-deleted'), 'chartType' => 'line', 'values' => $deleted ] ], 'yMarkers' => [ [ 'label' => sprintf('%s = %.1f', self::trans('app.chart-average'), $avg), 'value' => collect($created)->avg(), 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * Get all users chart */ protected function chartUsersAll(): array { $weeks = 54; $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); $created = DB::table('users') ->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt") ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1'); $deleted = DB::table('users') ->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt") ->where('deleted_at', '>=', $start->toDateString()) ->groupByRaw('1'); $created = $this->applyTenantScope($created)->get(); $deleted = $this->applyTenantScope($deleted)->get(); $count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count(); $empty = array_fill_keys($labels, 0); $created = array_merge($empty, $created->pluck('cnt', 'period')->all()); $deleted = array_merge($empty, $deleted->pluck('cnt', 'period')->all()); $all = []; foreach (array_reverse($labels) as $label) { $all[] = $count; $count -= $created[$label] - $deleted[$label]; } $all = array_reverse($all); // $start = 3000; // for ($i = 0; $i < count($labels); $i++) { // $all[$i] = $start + $i * 15; // } // See https://frappe.io/charts/docs for format/options description return [ 'title' => self::trans('app.chart-allusers'), 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $all ] ] ] ]; } /** * Get vouchers chart */ protected function chartVouchers(): array { $vouchers = DB::table('wallets') ->selectRaw("count(discount_id) as cnt, code") ->join('discounts', 'discounts.id', '=', 'wallets.discount_id') ->join('users', 'users.id', '=', 'wallets.user_id') ->where('discount', '>', 0) ->whereNotNull('code') ->whereNull('users.deleted_at') ->groupBy('discounts.code') ->havingRaw("count(discount_id) > 0") ->orderByRaw('1'); $addTenantScope = function ($builder, $tenantId) { return $builder->where('users.tenant_id', $tenantId); }; $vouchers = $this->applyTenantScope($vouchers, $addTenantScope) ->pluck('cnt', 'code')->all(); $labels = array_keys($vouchers); $vouchers = array_values($vouchers); // $labels = ["TEST", "NEW", "OTHER", "US"]; // $vouchers = [100, 120, 30, 50]; return $this->donutChart(self::trans('app.chart-vouchers'), $labels, $vouchers); } protected static function donutChart($title, $labels, $data): array { // See https://frappe.io/charts/docs for format/options description return [ 'title' => $title, 'type' => 'donut', 'colors' => [ self::COLOR_BLUE, self::COLOR_BLUE_DARK, self::COLOR_GREEN, self::COLOR_GREEN_DARK, self::COLOR_ORANGE, self::COLOR_RED, self::COLOR_RED_DARK ], 'maxSlices' => 8, 'tooltipOptions' => [], // does not work without it (https://github.com/frappe/charts/issues/314) 'data' => [ 'labels' => $labels, 'datasets' => [ [ 'values' => $data ] ] ] ]; } /** * Add tenant scope to the queries when needed * * @param \Illuminate\Database\Query\Builder $query The query * @param callable $addQuery Additional tenant-scope query-modifier * * @return \Illuminate\Database\Query\Builder */ protected function applyTenantScope($query, $addQuery = null) { // TODO: Per-tenant stats for admins return $query; } /** * Get the currency for stats * * @return string Currency code */ protected function currency() { $user = $this->guard()->user(); // For resellers return their wallet currency if ($user->role == 'reseller') { $currency = $user->wallet()->currency; } // System currency for others return \config('app.currency'); } /** * Get collected stats for a specific type/period * * @param int $type Chart * @param int $weeks Number of weeks back from now * @param ?callable $itemCallback A callback to execute on every stat item * * @return array [ labels, stats ] */ protected function getCollectedStats(int $type, int $weeks, $itemCallback = null): array { $start = Carbon::now(); $labels = []; while ($weeks > 0) { $labels[] = $start->format('Y-W'); $weeks--; if ($weeks) { $start->subWeeks(1); } } $labels = array_reverse($labels); $start->startOfWeek(Carbon::MONDAY); // Get the stats grouped by tenant and week $stats = DB::table('stats') ->selectRaw("tenant_id, date_format(created_at, '%Y-%v') as period, avg(value) as cnt") ->where('type', $type) ->where('created_at', '>=', $start->toDateString()) ->groupByRaw('1,2'); // Get the query result and sum up per-tenant stats $result = []; $this->applyTenantScope($stats)->get() ->each(function ($item) use (&$result) { $result[$item->period] = ($result[$item->period] ?? 0) + $item->cnt; }); // Process the result, e.g. convert values to int if ($itemCallback) { $result = array_map($itemCallback, $result); } // Fill the missing weeks with zeros $result = array_values(array_merge(array_fill_keys($labels, 0), $result)); return [$labels, $result]; } } diff --git a/src/app/Providers/Payment/Coinbase.php b/src/app/Providers/Payment/Coinbase.php index 11a3244b..aeee2519 100644 --- a/src/app/Providers/Payment/Coinbase.php +++ b/src/app/Providers/Payment/Coinbase.php @@ -1,398 +1,398 @@ tag */ public function customerLink(Wallet $wallet): ?string { return null; } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents (optional) * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function createMandate(Wallet $wallet, array $payment): ?array { throw new \Exception("not implemented"); } /** * 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 { throw new \Exception("not implemented"); } /** * Get a auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { throw new \Exception("not implemented"); } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'coinbase'; } /** * Creates HTTP client for connections to coinbase * * @return \GuzzleHttp\Client HTTP client instance */ private function client() { if (self::$testClient) { return self::$testClient; } if (!$this->client) { $this->client = new \GuzzleHttp\Client( [ 'http_errors' => false, // No exceptions from Guzzle 'base_uri' => 'https://api.commerce.coinbase.com/', 'verify' => \config('services.coinbase.api_verify_tls'), 'headers' => [ 'X-CC-Api-Key' => \config('services.coinbase.key'), 'X-CC-Version' => '2018-03-22', ], 'connect_timeout' => 10, 'timeout' => 10, 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { $threshold = \config('logging.slow_log'); if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { $url = $stats->getEffectiveUri(); $method = $stats->getRequest()->getMethod(); \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); } }, ] ); } return $this->client; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: oneoff/recurring * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == Payment::TYPE_RECURRING) { throw new \Exception("not supported"); } $amount = $payment['amount'] / 100; $post = [ 'json' => [ "name" => \config('app.name'), "description" => $payment['description'], "pricing_type" => "fixed_price", 'local_price' => [ 'currency' => $wallet->currency, - 'amount' => sprintf('%.2f', $amount), + 'amount' => sprintf('%.2F', $amount), ], 'redirect_url' => self::redirectUrl() ] ]; $response = $this->client()->request('POST', '/charges/', $post); $code = $response->getStatusCode(); if ($code == 429) { $this->logError("Ratelimiting", $response); throw new \Exception("Failed to create coinbase charge due to rate-limiting: {$code}"); } if ($code !== 201) { $this->logError("Failed to create coinbase charge", $response); throw new \Exception("Failed to create coinbase charge: {$code}"); } $json = json_decode($response->getBody(), true); // Store the payment reference in database $payment['status'] = Payment::STATUS_OPEN; //We take the code instead of the id because it fits into our current db schema and the id doesn't $payment['id'] = $json['data']['code']; //We store in satoshis (the database stores it as INTEGER type) $payment['currency_amount'] = $json['data']['pricing']['bitcoin']['amount'] * self::SATOSHI_MULTIPLIER; $payment['currency'] = 'BTC'; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'newWindowUrl' => $json['data']['hosted_url'] ]; } /** * Log an error for a failed request to the meet server * * @param string $str The error string * @param object $response Guzzle client response */ private function logError(string $str, $response) { $code = $response->getStatusCode(); if ($code != 200 && $code != 201) { \Log::error(var_export($response)); $decoded = json_decode($response->getBody(), true); $message = ""; if ( is_array($decoded) && array_key_exists('error', $decoded) && is_array($decoded['error']) && array_key_exists('message', $decoded['error']) ) { $message = $decoded['error']['message']; } \Log::error("$str [$code]: $message"); } } /** * Cancel a pending payment. * * @param \App\Wallet $wallet The wallet * @param string $paymentId Payment Id * * @return bool True on success, False on failure */ public function cancel(Wallet $wallet, $paymentId): bool { $response = $this->client()->request('POST', "/charges/{$paymentId}/cancel"); if ($response->getStatusCode() == 200) { $db_payment = Payment::find($paymentId); $db_payment->status = Payment::STATUS_CANCELED; $db_payment->save(); } else { $this->logError("Failed to cancel payment", $response); return false; } return true; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { throw new \Exception("not available with coinbase"); } private static function verifySignature($payload, $sigHeader) { $secret = \config('services.coinbase.webhook_secret'); $computedSignature = \hash_hmac('sha256', $payload, $secret); if (!\hash_equals($sigHeader, $computedSignature)) { throw new \Exception("Coinbase request signature verification failed"); } } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { // We cannot just use php://input as it's already "emptied" by the framework $request = Request::instance(); $payload = $request->getContent(); $sigHeader = $request->header('X-CC-Webhook-Signature'); self::verifySignature($payload, $sigHeader); $data = \json_decode($payload, true); $event = $data['event']; $type = $event['type']; \Log::info("Coinbase webhook called " . $type); if ($type == 'charge:created') { return 200; } if ($type == 'charge:confirmed') { return 200; } if ($type == 'charge:pending') { return 200; } $payment_id = $event['data']['code']; if (empty($payment_id)) { \Log::warning(sprintf('Failed to find the payment for (%s)', $payment_id)); return 200; } $payment = Payment::find($payment_id); if (empty($payment)) { return 200; } $newStatus = Payment::STATUS_PENDING; // Even if we receive the payment delayed, we still have the money, and therefore credit it. if ($type == 'charge:resolved' || $type == 'charge:delayed') { // The payment is paid. Update the balance if ($payment->status != Payment::STATUS_PAID && $payment->amount > 0) { $credit = true; } $newStatus = Payment::STATUS_PAID; } elseif ($type == 'charge:failed') { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Coinbase payment failed (%s)', $payment->id)); $newStatus = Payment::STATUS_FAILED; } DB::beginTransaction(); // This is a sanity check, just in case the payment provider api // sent us open -> paid -> open -> paid. So, we lock the payment after // recivied a "final" state. $pending_states = [Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED]; if (in_array($payment->status, $pending_states)) { $payment->status = $newStatus; $payment->save(); } if (!empty($credit)) { $payment->credit('Coinbase'); } DB::commit(); return 200; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * @param string $currency Currency code * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods(string $type, string $currency): array { $availableMethods = []; if ($type == Payment::TYPE_ONEOFF) { $availableMethods['bitcoin'] = [ 'id' => 'bitcoin', 'name' => "Bitcoin", 'minimumAmount' => 0.001, 'currency' => 'BTC' ]; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { $payment = Payment::find($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => true, 'checkoutUrl' => "https://commerce.coinbase.com/charges/{$paymentId}" ]; } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php index b31f2fd3..36213963 100644 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -1,630 +1,630 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::mollieCustomerId($wallet, false); if (!$customer_id) { return null; } return sprintf( '%s', $customer_id, $customer_id ); } /** * Validates that mollie available. * * @throws \Mollie\Api\Exceptions\ApiException on failure * @return bool true on success */ public static function healthcheck() { mollie()->methods()->allActive(); return true; } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents (optional) * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * - redirectUrl: The location to goto after checkout * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function createMandate(Wallet $wallet, array $payment): ?array { // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); if (!isset($payment['amount'])) { $payment['amount'] = 0; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], - 'value' => sprintf('%.2f', $amount / 100), + 'value' => sprintf('%.2F', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'redirectUrl' => $payment['redirectUrl'] ?? self::redirectUrl(), 'locale' => 'en_US', 'method' => $payment['methodId'] ]; // Create the payment in Mollie $response = mollie()->payments()->create($request); if ($response->mandateId) { $wallet->setSetting('mollie_mandate_id', $response->mandateId); } // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $payment['type'] = Payment::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); return [ 'id' => $response->id, 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Revoke the auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { // Get the Mandate info $mandate = self::mollieMandate($wallet); // Revoke the mandate on Mollie if ($mandate) { $mandate->revoke(); $wallet->setSetting('mollie_mandate_id', null); } return true; } /** * Get a auto-payment mandate for the wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { // Get the Mandate info $mandate = self::mollieMandate($wallet); if (empty($mandate)) { return null; } $result = [ 'id' => $mandate->id, 'isPending' => $mandate->isPending(), 'isValid' => $mandate->isValid(), 'method' => self::paymentMethod($mandate, 'Unknown method'), 'methodId' => $mandate->method ]; return $result; } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'mollie'; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: oneoff/recurring * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == Payment::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required (note that JPK and ISK don't require decimals, // but we're not using them currently) - 'value' => sprintf('%.2f', $amount / 100), + 'value' => sprintf('%.2F', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'redirectUrl' => self::redirectUrl() // required for non-recurring payments ]; // TODO: Additional payment parameters for better fraud protection: // billingEmail - for bank transfers, Przelewy24, but not creditcard // billingAddress (it is a structured field not just text) // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Cancel a pending payment. * * @param \App\Wallet $wallet The wallet * @param string $paymentId Payment Id * * @return bool True on success, False on failure */ public function cancel(Wallet $wallet, $paymentId): bool { $response = mollie()->payments()->delete($paymentId); $db_payment = Payment::find($paymentId); $db_payment->status = $response->status; $db_payment->save(); return true; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::mollieMandate($wallet); if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { \Log::debug("Recurring payment for {$wallet->id}: no valid Mollie mandate"); return null; } $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required - 'value' => sprintf('%.2f', $amount / 100), + 'value' => sprintf('%.2F', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'mandateId' => $mandate->id ]; \Log::debug("Recurring payment for {$wallet->id}: " . json_encode($request)); // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; DB::beginTransaction(); $payment = $this->storePayment($payment, $wallet->id); // Mollie can return 'paid' status immediately, so we don't // have to wait for the webhook. What's more, the webhook would ignore // the payment because it will be marked as paid before the webhook. // Let's handle paid status here too. if ($response->isPaid()) { self::creditPayment($payment, $response); $notify = true; } elseif ($response->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $response->id)); // Disable the mandate $wallet->setSetting('mandate_disabled', 1); $notify = true; } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { $payment_id = \request()->input('id'); if (empty($payment_id)) { return 200; } $payment = Payment::find($payment_id); if (empty($payment)) { // Mollie recommends to return "200 OK" even if the payment does not exist return 200; } try { // Get the payment details from Mollie // TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed $mollie_payment = mollie()->payments()->get($payment_id); $refunds = []; if ($mollie_payment->isPaid()) { // The payment is paid. Update the balance, and notify the user if ($payment->status != Payment::STATUS_PAID && $payment->amount >= 0) { $credit = true; $notify = $payment->type == Payment::TYPE_RECURRING; } // The payment has been (partially) refunded. // Let's process refunds with status "refunded". if ($mollie_payment->hasRefunds()) { foreach ($mollie_payment->refunds() as $refund) { if ($refund->isTransferred() && $refund->amount->value) { $refunds[] = [ 'id' => $refund->id, 'description' => $refund->description, 'amount' => round(floatval($refund->amount->value) * 100), 'type' => Payment::TYPE_REFUND, 'currency' => $refund->amount->currency ]; } } } // The payment has been (partially) charged back. // Let's process chargebacks (they have no states as refunds) if ($mollie_payment->hasChargebacks()) { foreach ($mollie_payment->chargebacks() as $chargeback) { if ($chargeback->amount->value) { $refunds[] = [ 'id' => $chargeback->id, 'amount' => round(floatval($chargeback->amount->value) * 100), 'type' => Payment::TYPE_CHARGEBACK, 'currency' => $chargeback->amount->currency ]; } } } // In case there were multiple auto-payment setup requests (e.g. caused by a double // form submission) we end up with multiple payment records and mollie_mandate_id // pointing to the one from the last payment not the successful one. // We make sure to use mandate id from the successful "first" payment. if ( $payment->type == Payment::TYPE_MANDATE && $mollie_payment->mandateId && $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST ) { $payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId); } } elseif ($mollie_payment->isFailed()) { // Note: I didn't find a way to get any description of the problem with a payment \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); // Disable the mandate if ($payment->type == Payment::TYPE_RECURRING) { $notify = true; $payment->wallet->setSetting('mandate_disabled', 1); } } DB::beginTransaction(); // This is a sanity check, just in case the payment provider api // sent us open -> paid -> open -> paid. So, we lock the payment after // recivied a "final" state. $pending_states = [Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED]; if (in_array($payment->status, $pending_states)) { $payment->status = $mollie_payment->status; $payment->save(); } if (!empty($credit)) { self::creditPayment($payment, $mollie_payment); } foreach ($refunds as $refund) { $payment->refund($refund); } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } } catch (\Mollie\Api\Exceptions\ApiException $e) { \Log::warning(sprintf('Mollie api call failed (%s)', $e->getMessage())); } return 200; } /** * Get Mollie customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return ?string Mollie customer identifier */ protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('mollie_id'); // Register the user in Mollie if (empty($customer_id) && $create) { $customer = mollie()->customers()->create([ 'name' => $wallet->owner->name(), 'email' => $wallet->id . '@private.' . \config('app.domain'), ]); $customer_id = $customer->id; $wallet->setSetting('mollie_id', $customer->id); } return $customer_id; } /** * Get the active Mollie auto-payment mandate */ protected static function mollieMandate(Wallet $wallet) { $settings = $wallet->getSettings(['mollie_id', 'mollie_mandate_id']); // Get the manadate reference we already have if ($settings['mollie_id'] && $settings['mollie_mandate_id']) { try { return mollie()->mandates()->getForId($settings['mollie_id'], $settings['mollie_mandate_id']); } catch (ApiException $e) { // FIXME: What about 404? if ($e->getCode() == 410) { // The mandate is gone, remove the reference $wallet->setSetting('mollie_mandate_id', null); return null; } // TODO: Maybe we shouldn't always throw? It make sense in the job // but for example when we're just fetching wallet info... throw $e; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment($payment, $mollie_payment) { // Extract the payment method for transaction description $method = self::paymentMethod($mollie_payment, 'Mollie'); $payment->credit($method); } /** * Extract payment method description from Mollie payment/mandate details */ protected static function paymentMethod($object, $default = ''): string { $details = $object->details; // Mollie supports 3 methods here switch ($object->method) { case self::METHOD_CREDITCARD: // If the customer started, but never finished the 'first' payment // card details will be empty, and mandate will be 'pending'. if (empty($details->cardNumber)) { return 'Credit Card'; } return sprintf( '%s (**** **** **** %s)', $details->cardLabel ?: 'Card', // @phpstan-ignore-line $details->cardNumber ); case self::METHOD_DIRECTDEBIT: return sprintf('Direct Debit (%s)', $details->customerAccount); case self::METHOD_PAYPAL: return sprintf('PayPal (%s)', $details->consumerAccount); } return $default; } /** * List supported payment methods. * * @param string $type The payment type for which we require a method (oneoff/recurring). * @param string $currency Currency code * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public function providerPaymentMethods(string $type, string $currency): array { // Prefer methods in the system currency $providerMethods = (array) mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => $currency ] ] ); // Get EUR methods (e.g. bank transfers are in EUR only) if ($currency != 'EUR') { $eurMethods = (array) mollie()->methods()->allActive( [ 'sequenceType' => $type, 'amount' => [ 'value' => '1.00', 'currency' => 'EUR' ] ] ); // Later provider methods will override earlier ones $providerMethods = array_merge($eurMethods, $providerMethods); } $availableMethods = []; foreach ($providerMethods as $method) { $availableMethods[$method->id] = [ 'id' => $method->id, 'name' => $method->description, 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents 'currency' => $method->minimumAmount->currency, 'exchangeRate' => \App\Utils::exchangeRate($currency, $method->minimumAmount->currency) ]; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { $payment = mollie()->payments()->get($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => $payment->isCancelable, 'checkoutUrl' => $payment->getCheckoutUrl() ]; } } diff --git a/src/app/Utils.php b/src/app/Utils.php index bec797a8..df44ea73 100644 --- a/src/app/Utils.php +++ b/src/app/Utils.php @@ -1,621 +1,621 @@ country ? $net->country : $fallback; } /** * Return the country ISO code for the current request. */ public static function countryForRequest() { $request = \request(); $ip = $request->ip(); return self::countryForIP($ip); } /** * Return the number of days in the month prior to this one. * * @return int */ public static function daysInLastMonth() { $start = new Carbon('first day of last month'); $end = new Carbon('last day of last month'); return $start->diffInDays($end) + 1; } /** * Download a file from the interwebz and store it locally. * * @param string $source The source location * @param string $target The target location * @param bool $force Force the download (and overwrite target) * * @return void */ public static function downloadFile($source, $target, $force = false) { if (is_file($target) && !$force) { return; } \Log::info("Retrieving {$source}"); $fp = fopen($target, 'w'); $curl = curl_init(); curl_setopt($curl, CURLOPT_URL, $source); curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); curl_setopt($curl, CURLOPT_FILE, $fp); curl_exec($curl); if (curl_errno($curl)) { \Log::error("Request error on {$source}: " . curl_error($curl)); curl_close($curl); fclose($fp); unlink($target); return; } curl_close($curl); fclose($fp); } /** * Converts an email address to lower case. Keeps the LMTP shared folder * addresses character case intact. * * @param string $email Email address * * @return string Email address */ public static function emailToLower(string $email): string { // For LMTP shared folder address lower case the domain part only if (str_starts_with($email, 'shared+shared/')) { $pos = strrpos($email, '@'); $domain = substr($email, $pos + 1); $local = substr($email, 0, strlen($email) - strlen($domain) - 1); return $local . '@' . strtolower($domain); } return strtolower($email); } /** * Generate a passphrase. Not intended for use in production, so limited to environments that are not production. * * @return string */ public static function generatePassphrase() { if (\config('app.env') == 'production') { throw new \Exception("Thou shall not pass!"); } if (\config('app.passphrase')) { return \config('app.passphrase'); } $alphaLow = 'abcdefghijklmnopqrstuvwxyz'; $alphaUp = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $num = '0123456789'; $stdSpecial = '~`!@#$%^&*()-_+=[{]}\\|\'";:/?.>,<'; $source = $alphaLow . $alphaUp . $num . $stdSpecial; $result = ''; for ($x = 0; $x < 16; $x++) { $result .= substr($source, rand(0, (strlen($source) - 1)), 1); } return $result; } /** * Find an object that is the recipient for the specified address. * * @param string $address * * @return array */ public static function findObjectsByRecipientAddress($address) { $address = \App\Utils::normalizeAddress($address); list($local, $domainName) = explode('@', $address); $domain = \App\Domain::where('namespace', $domainName)->first(); if (!$domain) { return []; } $user = \App\User::where('email', $address)->first(); if ($user) { return [$user]; } $userAliases = \App\UserAlias::where('alias', $address)->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } $userAliases = \App\UserAlias::where('alias', "catchall@{$domain->namespace}")->get(); if (count($userAliases) > 0) { $users = []; foreach ($userAliases as $userAlias) { $users[] = $userAlias->user; } return $users; } return []; } /** * Retrieve the network ID and Type from a client address * * @param string $clientAddress The IPv4 or IPv6 address. * * @return array An array of ID and class or null and null. */ public static function getNetFromAddress($clientAddress) { if (strpos($clientAddress, ':') === false) { $net = \App\IP4Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP4Net::class]; } } else { $net = \App\IP6Net::getNet($clientAddress); if ($net) { return [$net->id, \App\IP6Net::class]; } } return [null, null]; } /** * Calculate the broadcast address provided a net number and a prefix. * * @param string $net A valid IPv6 network number. * @param int $prefix The network prefix. * * @return string */ public static function ip6Broadcast($net, $prefix) { $netHex = bin2hex(inet_pton($net)); // Overwriting first address string to make sure notation is optimal $net = inet_ntop(hex2bin($netHex)); // Calculate the number of 'flexible' bits $flexbits = 128 - $prefix; // Build the hexadecimal string of the last address $lastAddrHex = $netHex; // We start at the end of the string (which is always 32 characters long) $pos = 31; while ($flexbits > 0) { // Get the character at this position $orig = substr($lastAddrHex, $pos, 1); // Convert it to an integer $origval = hexdec($orig); // OR it with (2^flexbits)-1, with flexbits limited to 4 at a time $newval = $origval | (pow(2, min(4, $flexbits)) - 1); // Convert it back to a hexadecimal character $new = dechex($newval); // And put that character back in the string $lastAddrHex = substr_replace($lastAddrHex, $new, $pos, 1); // We processed one nibble, move to previous position $flexbits -= 4; $pos -= 1; } // Convert the hexadecimal string to a binary string $lastaddrbin = hex2bin($lastAddrHex); // And create an IPv6 address from the binary string $lastaddrstr = inet_ntop($lastaddrbin); return $lastaddrstr; } /** * Normalize an email address. * * This means to lowercase and strip components separated with recipient delimiters. * * @param ?string $address The address to normalize * @param bool $asArray Return an array with local and domain part * * @return string|array Normalized email address as string or array */ public static function normalizeAddress(?string $address, bool $asArray = false) { if ($address === null || $address === '') { return $asArray ? ['', ''] : ''; } $address = self::emailToLower($address); if (strpos($address, '@') === false) { return $asArray ? [$address, ''] : $address; } list($local, $domain) = explode('@', $address); if (strpos($local, '+') !== false) { $local = explode('+', $local)[0]; } return $asArray ? [$local, $domain] : "{$local}@{$domain}"; } /** * Provide all unique combinations of elements in $input, with order and duplicates irrelevant. * * @param array $input The input array of elements. * * @return array[] */ public static function powerSet(array $input): array { $output = []; for ($x = 0; $x < count($input); $x++) { self::combine($input, $x + 1, 0, [], 0, $output); } return $output; } /** * Returns the current user's email address or null. * * @return string */ public static function userEmailOrNull(): ?string { $user = Auth::user(); if (!$user) { return null; } return $user->email; } /** * Returns a random string consisting of a quantity of segments of a certain length joined. * * Example: * * ```php * $roomName = strtolower(\App\Utils::randStr(3, 3, '-'); * // $roomName == '3qb-7cs-cjj' * ``` * * @param int $length The length of each segment * @param int $qty The quantity of segments * @param string $join The string to use to join the segments * * @return string */ public static function randStr($length, $qty = 1, $join = '') { $chars = env('SHORTCODE_CHARS', self::CHARS); $randStrs = []; for ($x = 0; $x < $qty; $x++) { $randStrs[$x] = []; for ($y = 0; $y < $length; $y++) { $randStrs[$x][] = $chars[rand(0, strlen($chars) - 1)]; } shuffle($randStrs[$x]); $randStrs[$x] = implode('', $randStrs[$x]); } return implode($join, $randStrs); } /** * Returns a UUID in the form of an integer. * * @return int */ public static function uuidInt(): int { $hex = self::uuidStr(); $bin = pack('h*', str_replace('-', '', $hex)); $ids = unpack('L', $bin); $id = array_shift($ids); return $id; } /** * Returns a UUID in the form of a string. * * @return string */ public static function uuidStr(): string { return (string) Str::uuid(); } private static function combine($input, $r, $index, $data, $i, &$output): void { $n = count($input); // Current cobination is ready if ($index == $r) { $output[] = array_slice($data, 0, $r); return; } // When no more elements are there to put in data[] if ($i >= $n) { return; } // current is included, put next at next location $data[$index] = $input[$i]; self::combine($input, $r, $index + 1, $data, $i + 1, $output); // current is excluded, replace it with next (Note that i+1 // is passed, but index is not changed) self::combine($input, $r, $index, $data, $i + 1, $output); } /** * Create self URL * * @param string $route Route/Path/URL * @param int|null $tenantId Current tenant * * @todo Move this to App\Http\Controllers\Controller * * @return string Full URL */ public static function serviceUrl(string $route, $tenantId = null): string { if (preg_match('|^https?://|i', $route)) { return $route; } $url = \App\Tenant::getConfig($tenantId, 'app.public_url'); if (!$url) { $url = \App\Tenant::getConfig($tenantId, 'app.url'); } return rtrim(trim($url, '/') . '/' . ltrim($route, '/'), '/'); } /** * Create a configuration/environment data to be passed to * the UI * * @todo Move this to App\Http\Controllers\Controller * * @return array Configuration data */ public static function uiEnv(): array { $countries = include resource_path('countries.php'); $req_domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); $sys_domain = \config('app.domain'); $opts = [ 'app.name', 'app.url', 'app.domain', 'app.theme', 'app.webmail_url', 'app.support_email', 'app.company.copyright', 'app.companion_download_link', 'app.with_signup', 'mail.from.address' ]; $env = \app('config')->getMany($opts); $env['countries'] = $countries ?: []; $env['view'] = 'root'; $env['jsapp'] = 'user.js'; if ($req_domain == "admin.$sys_domain") { $env['jsapp'] = 'admin.js'; } elseif ($req_domain == "reseller.$sys_domain") { $env['jsapp'] = 'reseller.js'; } $env['paymentProvider'] = \config('services.payment_provider'); $env['stripePK'] = \config('services.stripe.public_key'); $env['languages'] = \App\Http\Controllers\ContentController::locales(); $env['menu'] = \App\Http\Controllers\ContentController::menu(); return $env; } /** * Set test exchange rates. * * @param array $rates: Exchange rates */ public static function setTestExchangeRates(array $rates): void { self::$testRates = $rates; } /** * Retrieve an exchange rate. * * @param string $sourceCurrency: Currency from which to convert * @param string $targetCurrency: Currency to convert to * * @return float Exchange rate */ public static function exchangeRate(string $sourceCurrency, string $targetCurrency): float { if (strcasecmp($sourceCurrency, $targetCurrency) == 0) { return 1.0; } if (isset(self::$testRates[$targetCurrency])) { return floatval(self::$testRates[$targetCurrency]); } $currencyFile = resource_path("exchangerates-$sourceCurrency.php"); //Attempt to find the reverse exchange rate, if we don't have the file for the source currency if (!file_exists($currencyFile)) { $rates = include resource_path("exchangerates-$targetCurrency.php"); if (!isset($rates[$sourceCurrency])) { throw new \Exception("Failed to find the reverse exchange rate for " . $sourceCurrency); } return 1.0 / floatval($rates[$sourceCurrency]); } $rates = include $currencyFile; if (!isset($rates[$targetCurrency])) { throw new \Exception("Failed to find exchange rate for " . $targetCurrency); } return floatval($rates[$targetCurrency]); } /** * A helper to display human-readable amount of money using * for specified currency and locale. * * @param int $amount Amount of money (in cents) * @param string $currency Currency code * @param string $locale Output locale * * @return string String representation, e.g. "9.99 CHF" */ public static function money(int $amount, $currency, $locale = 'de_DE'): string { $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); $result = $nf->formatCurrency(round($amount / 100, 2), $currency); // Replace non-breaking space return str_replace("\xC2\xA0", " ", $result); } /** * A helper to display human-readable percent value * for specified currency and locale. * * @param int|float $percent Percent value (0 to 100) * @param string $locale Output locale * * @return string String representation, e.g. "0 %", "7.7 %" */ public static function percent(int|float $percent, $locale = 'de_DE'): string { $nf = new \NumberFormatter($locale, \NumberFormatter::PERCENT); $sep = $nf->getSymbol(\NumberFormatter::DECIMAL_SEPARATOR_SYMBOL); - $result = sprintf('%.2f', $percent); + $result = sprintf('%.2F', $percent); $result = preg_replace('/\.00/', '', $result); $result = preg_replace('/(\.[0-9])0/', '\\1', $result); $result = str_replace('.', $sep, $result); return $result . ' %'; } }