diff --git a/src/app/Console/Commands/Data/Import/OpenExchangeRatesCommand.php b/src/app/Console/Commands/Data/Import/OpenExchangeRatesCommand.php index 8a21f5fd..8e5116b6 100644 --- a/src/app/Console/Commands/Data/Import/OpenExchangeRatesCommand.php +++ b/src/app/Console/Commands/Data/Import/OpenExchangeRatesCommand.php @@ -1,49 +1,46 @@ $rate) { + $out .= sprintf(" '%s' => '%s',\n", $countryCode, $rate); + } - foreach ($rates as $countryCode => $rate) { - $out .= sprintf(" '%s' => '%s',\n", $countryCode, $rate); - } - - $out .= "];\n"; + $out .= "];\n"; - file_put_contents($file, $out); + file_put_contents($file, $out); + } } } diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php index 488f9e67..67fd8966 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(); $wallet->id = \App\Utils::uuidStr(); - $wallet->owner = new User(['id' => 123456789]); // @phpstan-ignore-line + $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) { $country = 'CH'; $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(), ], ]); } else { $customer = $this->customerData(); $country = $this->wallet->owner->getSetting('country'); $items = $this->wallet->payments() ->where('status', PaymentProvider::STATUS_PAID) ->where('updated_at', '>=', $start) ->where('updated_at', '<', $end) ->where('amount', '<>', 0) ->orderBy('updated_at') ->get(); } $vatRate = \config('app.vat.rate'); $vatCountries = explode(',', \config('app.vat.countries')); $vatCountries = array_map('strtoupper', array_map('trim', $vatCountries)); if (!$country || !in_array(strtoupper($country), $vatCountries)) { $vatRate = 0; } $totalVat = 0; $total = 0; $items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) { $amount = $item->amount; if ($vatRate > 0) { $amount = round($amount * ((100 - $vatRate) / 100)); $totalVat += $item->amount - $amount; } $total += $amount; $type = $item->type ?? null; if ($type == PaymentProvider::TYPE_REFUND) { $description = \trans('documents.receipt-refund'); } elseif ($type == PaymentProvider::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)), '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/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php index e85e4df1..e00b6da8 100644 --- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php @@ -1,367 +1,390 @@ 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); // See https://frappe.io/charts/docs for format/options description return [ 'title' => 'Discounts', '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' => $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); - $payments = DB::table('payments') - ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(amount) as amount") + // 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(amount) as amount, wallets.currency") + ->join('wallets', 'wallets.id', '=', 'wallet_id') ->where('updated_at', '>=', $start->toDateString()) ->where('status', PaymentProvider::STATUS_PAID) ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING]) - ->groupByRaw('1'); + ->groupByRaw('period, wallets.currency'); $addTenantScope = function ($builder, $tenantId) { - $where = '`wallet_id` IN (' - . 'select `id` from `wallets` ' - . 'join `users` on (`wallets`.`user_id` = `users`.`id`) ' - . 'where `payments`.`wallet_id` = `wallets`.`id` ' - . 'and `users`.`tenant_id` = ' . intval($tenantId) - . ')'; + $where = sprintf( + '`wallets`.`user_id` IN (select `id` from `users` where `tenant_id` = %d)', + $tenantId + ); return $builder->whereRaw($where); }; - $payments = $this->applyTenantScope($payments, $addTenantScope) - ->pluck('amount', 'period') - ->map(function ($amount) { - return $amount / 100; + $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->all())); + $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' => 'Income in CHF - last 8 weeks', + 'title' => "Income in {$currency} - last 8 weeks", 'type' => 'bar', 'colors' => [self::COLOR_BLUE], 'axisOptions' => [ 'xIsSeries' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Payments', 'values' => $payments ] ], 'yMarkers' => [ [ 'label' => sprintf('average = %.2f', $avg), 'value' => $avg, 'options' => [ 'labelPos' => 'left' ] // default: 'right' ] ] ] ]; } /** * 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' => 'Users - last 8 weeks', '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' => 'Created', 'chartType' => 'bar', 'values' => $created ], [ 'name' => 'Deleted', 'chartType' => 'line', 'values' => $deleted ] ], 'yMarkers' => [ [ 'label' => sprintf('average = %.1f', $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' => 'All Users - last year', 'type' => 'line', 'colors' => [self::COLOR_GREEN], 'axisOptions' => [ 'xIsSeries' => true, 'xAxisMode' => 'tick', ], 'lineOptions' => [ 'hideDots' => true, 'regionFill' => true, ], 'data' => [ 'labels' => $labels, 'datasets' => [ [ // 'name' => 'Existing', 'values' => $all ] ] ] ]; } /** * 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) { - $user = Auth::guard()->user(); + // 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') { - if ($addQuery) { - $query = $addQuery($query, \config('app.tenant_id')); - } else { - $query = $query->withEnvTenantContext(); - } + $currency = $user->wallet()->currency; } - return $query; + // System currency for others + return \config('app.currency'); } } diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php index b045406c..76e4e8cb 100644 --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -1,477 +1,480 @@ guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $mandate = self::walletMandate($wallet); return response()->json($mandate); } /** * Create a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateCreate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, ]); $mandate = [ - 'currency' => 'CHF', - 'description' => \config('app.name') . ' Auto-Payment Setup', + 'currency' => $wallet->currency, + 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup', 'methodId' => $request->methodId ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold // we'll top-up the wallet with the configured auto-payment amount if ($wallet->balance < intval($request->balance * 100)) { $mandate['amount'] = intval($request->amount * 100); } $provider = PaymentProvider::factory($wallet); $result = $provider->createMandate($wallet, $mandate); $result['status'] = 'success'; return response()->json($result); } /** * Revoke the auto-payment mandate. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateDelete() { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $provider = PaymentProvider::factory($wallet); $provider->deleteMandate($wallet); $wallet->setSetting('mandate_disabled', null); return response()->json([ 'status' => 'success', 'message' => \trans('app.mandate-delete-success'), ]); } /** * Update a new auto-payment mandate. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function mandateUpdate(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); // Input validation if ($errors = self::mandateValidate($request, $wallet)) { return response()->json(['status' => 'error', 'errors' => $errors], 422); } $wallet->setSettings([ 'mandate_amount' => $request->amount, 'mandate_balance' => $request->balance, // Re-enable the mandate to give it a chance to charge again // after it has been disabled (e.g. because the mandate amount was too small) 'mandate_disabled' => null, ]); // Trigger auto-payment if the balance is below the threshold if ($wallet->balance < intval($request->balance * 100)) { \App\Jobs\WalletCharge::dispatch($wallet); } $result = self::walletMandate($wallet); $result['status'] = 'success'; $result['message'] = \trans('app.mandate-update-success'); return response()->json($result); } /** * Validate an auto-payment mandate request. * * @param \Illuminate\Http\Request $request The API request. * @param \App\Wallet $wallet The wallet * * @return array|null List of errors on error or Null on success */ protected static function mandateValidate(Request $request, Wallet $wallet) { $rules = [ 'amount' => 'required|numeric', 'balance' => 'required|numeric|min:0', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return $v->errors()->toArray(); } $amount = (int) ($request->amount * 100); // Validate the minimum value // It has to be at least minimum payment amount and must cover current debt if ( $wallet->balance < 0 && $wallet->balance * -1 > PaymentProvider::MIN_AMOUNT && $wallet->balance + $amount < 0 ) { return ['amount' => \trans('validation.minamountdebt')]; } if ($amount < PaymentProvider::MIN_AMOUNT) { - $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); return ['amount' => \trans('validation.minamount', ['amount' => $min])]; } return null; } /** * Create a new payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function store(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $rules = [ 'amount' => 'required|numeric', ]; // Check required fields $v = Validator::make($request->all(), $rules); // TODO: allow comma as a decimal point? if ($v->fails()) { return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); } $amount = (int) ($request->amount * 100); // Validate the minimum value if ($amount < PaymentProvider::MIN_AMOUNT) { - $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $errors = ['amount' => \trans('validation.minamount', ['amount' => $min])]; return response()->json(['status' => 'error', 'errors' => $errors], 422); } $request = [ 'type' => PaymentProvider::TYPE_ONEOFF, 'currency' => $request->currency, 'amount' => $amount, 'methodId' => $request->methodId, - 'description' => \config('app.name') . ' Payment', + 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; $provider = PaymentProvider::factory($wallet); $result = $provider->payment($wallet, $request); $result['status'] = 'success'; return response()->json($result); } /** * Delete a pending payment. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ // TODO currently unused // public function cancel(Request $request) // { // $user = $this->guard()->user(); // // TODO: Wallet selection // $wallet = $user->wallets()->first(); // $paymentId = $request->payment; // $user_owns_payment = Payment::where('id', $paymentId) // ->where('wallet_id', $wallet->id) // ->exists(); // if (!$user_owns_payment) { // return $this->errorResponse(404); // } // $provider = PaymentProvider::factory($wallet); // if ($provider->cancel($wallet, $paymentId)) { // $result = ['status' => 'success']; // return response()->json($result); // } // return $this->errorResponse(404); // } /** * Update payment status (and balance). * * @param string $provider Provider name * * @return \Illuminate\Http\Response The response */ public function webhook($provider) { $code = 200; if ($provider = PaymentProvider::factory($provider)) { $code = $provider->webhook(); } return response($code < 400 ? 'Success' : 'Server error', $code); } /** * Top up a wallet with a "recurring" payment. * * @param \App\Wallet $wallet The wallet to charge * * @return bool True if the payment has been initialized */ public static function topUpWallet(Wallet $wallet): bool { $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); if (!empty($settings['mandate_disabled'])) { return false; } $min_balance = (int) (floatval($settings['mandate_balance']) * 100); $amount = (int) (floatval($settings['mandate_amount']) * 100); // The wallet balance is greater than the auto-payment threshold if ($wallet->balance >= $min_balance) { // Do nothing return false; } $provider = PaymentProvider::factory($wallet); $mandate = (array) $provider->getMandate($wallet); if (empty($mandate['isValid'])) { return false; } // The defined top-up amount is not enough // Disable auto-payment and notify the user if ($wallet->balance + $amount < 0) { // Disable (not remove) the mandate $wallet->setSetting('mandate_disabled', 1); \App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet); return false; } $request = [ 'type' => PaymentProvider::TYPE_RECURRING, - 'currency' => 'CHF', + 'currency' => $wallet->currency, 'amount' => $amount, 'methodId' => PaymentProvider::METHOD_CREDITCARD, - 'description' => \config('app.name') . ' Recurring Payment', + 'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment', ]; $result = $provider->payment($wallet, $request); return !empty($result); } /** * Returns auto-payment mandate info for the specified wallet * * @param \App\Wallet $wallet A wallet object * * @return array A mandate metadata */ public static function walletMandate(Wallet $wallet): array { $provider = PaymentProvider::factory($wallet); $settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']); // Get the Mandate info $mandate = (array) $provider->getMandate($wallet); $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100); $mandate['balance'] = 0; $mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled']; foreach (['amount', 'balance'] as $key) { if (($value = $settings["mandate_{$key}"]) !== null) { $mandate[$key] = $value; } } return $mandate; } /** * List supported payment methods. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function paymentMethods(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $methods = PaymentProvider::paymentMethods($wallet, $request->type); \Log::debug("Provider methods" . var_export(json_encode($methods), true)); return response()->json($methods); } /** * Check for pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function hasPayments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $exists = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ PaymentProvider::STATUS_OPEN, PaymentProvider::STATUS_PENDING, PaymentProvider::STATUS_AUTHORIZED]) ->exists(); return response()->json([ 'status' => 'success', 'hasPending' => $exists ]); } /** * List pending payments. * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function payments(Request $request) { $user = $this->guard()->user(); // TODO: Wallet selection $wallet = $user->wallets()->first(); $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $result = Payment::where('wallet_id', $wallet->id) ->where('type', PaymentProvider::TYPE_ONEOFF) ->whereIn('status', [ PaymentProvider::STATUS_OPEN, PaymentProvider::STATUS_PENDING, PaymentProvider::STATUS_AUTHORIZED]) ->orderBy('created_at', 'desc') ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } - $result = $result->map(function ($item) { + $result = $result->map(function ($item) use ($wallet) { $provider = PaymentProvider::factory($item->provider); $payment = $provider->getPayment($item->id); $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->description, 'amount' => $item->amount, + 'currency' => $wallet->currency, + // note: $item->currency/$item->currency_amount might be different 'status' => $item->status, 'isCancelable' => $payment['isCancelable'], 'checkoutUrl' => $payment['checkoutUrl'] ]; return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } } diff --git a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php index 46082df0..334f6500 100644 --- a/src/app/Http/Controllers/API/V4/Reseller/StatsController.php +++ b/src/app/Http/Controllers/API/V4/Reseller/StatsController.php @@ -1,37 +1,36 @@ user(); - if ($addQuery) { + $user = Auth::guard()->user(); $query = $addQuery($query, $user->tenant_id); } else { $query = $query->withSubjectTenantContext(); } return $query; } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index cc7d2ac5..388bf4e7 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,341 +1,342 @@ errorResponse(404); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** * Return data of the specified wallet. * * @param string $id A wallet identifier * * @return \Illuminate\Http\JsonResponse The response */ public function show($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->toArray(); $provider = \App\Providers\PaymentProvider::factory($wallet); $result['provider'] = $provider->name(); $result['notice'] = $this->getWalletNotice($wallet); return response()->json($result); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param string $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { return $this->errorResponse(404); } /** * Download a receipt in pdf format. * * @param string $id Wallet identifier * @param string $receipt Receipt identifier (YYYY-MM) * * @return \Illuminate\Http\Response */ public function receiptDownload($id, $receipt) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { abort(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { abort(403); } list ($year, $month) = explode('-', $receipt); if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { abort(404); } if ($receipt >= date('Y-m')) { abort(404); } $params = [ 'id' => sprintf('%04d-%02d', $year, $month), 'site' => \config('app.name') ]; $filename = \trans('documents.receipt-filename', $params); $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); $content = $receipt->pdfOutput(); return response($content) ->withHeaders([ 'Content-Type' => 'application/pdf', 'Content-Disposition' => 'attachment; filename="' . $filename . '"', 'Content-Length' => strlen($content), ]); } /** * Fetch wallet receipts list. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function receipts($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $result = $wallet->payments() ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') ->where('status', PaymentProvider::STATUS_PAID) ->where('amount', '<>', 0) ->orderBy('ident', 'desc') ->get() ->whereNotIn('ident', [date('Y-m')]) // exclude current month ->pluck('ident'); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => false, 'page' => 1, ]); } /** * Fetch wallet transactions. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); if (empty($wallet) || !$this->checkTenant($wallet->owner)) { return $this->errorResponse(404); } // Only owner (or admin) has access to the wallet if (!$this->guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } - $result = $result->map(function ($item) use ($isAdmin) { + $result = $result->map(function ($item) use ($isAdmin, $wallet) { $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $item->amount, + 'currency' => $wallet->currency, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } /** * Returns human readable notice about the wallet state. * * @param \App\Wallet $wallet The wallet */ protected function getWalletNotice(Wallet $wallet): ?string { // there is no credit if ($wallet->balance < 0) { return \trans('app.wallet-notice-nocredit'); } // the discount is 100%, no credit is needed if ($wallet->discount && $wallet->discount->discount == 100) { return null; } // the owner was created less than a month ago if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) { // but more than two weeks ago, notice of trial ending if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) { return \trans('app.wallet-notice-trial-end'); } return \trans('app.wallet-notice-trial'); } if ($until = $wallet->balanceLastsUntil()) { if ($until->isToday()) { return \trans('app.wallet-notice-today'); } // Once in a while we got e.g. "3 weeks" instead of expected "4 weeks". // It's because $until uses full seconds, but $now is more precise. // We make sure both have the same time set. $now = Carbon::now()->setTimeFrom($until); $diffOptions = [ 'syntax' => Carbon::DIFF_ABSOLUTE, 'parts' => 1, ]; if ($now->diff($until)->days > 31) { $diffOptions['parts'] = 2; } $params = [ 'date' => $until->toDateString(), 'days' => $now->diffForHumans($until, $diffOptions), ]; return \trans('app.wallet-notice-date', $params); } return null; } } diff --git a/src/app/Observers/UserObserver.php b/src/app/Observers/UserObserver.php index 1bc37b4e..c91ee919 100644 --- a/src/app/Observers/UserObserver.php +++ b/src/app/Observers/UserObserver.php @@ -1,378 +1,378 @@ id) { while (true) { $allegedly_unique = \App\Utils::uuidInt(); if (!User::withTrashed()->find($allegedly_unique)) { $user->{$user->getKeyName()} = $allegedly_unique; break; } } } $user->email = \strtolower($user->email); // only users that are not imported get the benefit of the doubt. $user->status |= User::STATUS_NEW | User::STATUS_ACTIVE; $user->tenant_id = \config('app.tenant_id'); } /** * Handle the "created" event. * * Ensures the user has at least one wallet. * * Should ensure some basic settings are available as well. * * @param \App\User $user The user created. * * @return void */ public function created(User $user) { $settings = [ 'country' => \App\Utils::countryForRequest(), - 'currency' => 'CHF', + 'currency' => \config('app.currency'), /* 'first_name' => '', 'last_name' => '', 'billing_address' => '', 'organization' => '', 'phone' => '', 'external_email' => '', */ ]; foreach ($settings as $key => $value) { $settings[$key] = [ 'key' => $key, 'value' => $value, 'user_id' => $user->id, ]; } // Note: Don't use setSettings() here to bypass UserSetting observers // Note: This is a single multi-insert query $user->settings()->insert(array_values($settings)); $user->wallets()->create(); // Create user record in LDAP, then check if the account is created in IMAP $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); if (\App\Tenant::getConfig($user->tenant_id, 'pgp.enable')) { \App\Jobs\PGP\KeyCreateJob::dispatch($user->id, $user->email); } } /** * Handle the "deleted" event. * * @param \App\User $user The user deleted. * * @return void */ public function deleted(User $user) { // Remove the user from existing groups $wallet = $user->wallet(); if ($wallet && $wallet->owner) { $wallet->owner->groups()->each(function ($group) use ($user) { if (in_array($user->email, $group->members)) { $group->members = array_diff($group->members, [$user->email]); $group->save(); } }); } // Debit the reseller's wallet with the user negative balance $balance = 0; foreach ($user->wallets as $wallet) { // Note: here we assume all user wallets are using the same currency. // It might get changed in the future $balance += $wallet->balance; } if ($balance < 0 && $user->tenant && ($wallet = $user->tenant->wallet())) { $wallet->debit($balance * -1, "Deleted user {$user->email}"); } } /** * Handle the "deleting" event. * * @param User $user The user that is being deleted. * * @return void */ public function deleting(User $user) { if ($user->isForceDeleting()) { $this->forceDeleting($user); return; } // TODO: Especially in tests we're doing delete() on a already deleted user. // Should we escape here - for performance reasons? // TODO: I think all of this should use database transactions // Entitlements do not have referential integrity on the entitled object, so this is our // way of doing an onDelete('cascade') without the foreign key. $entitlements = Entitlement::where('entitleable_id', $user->id) ->where('entitleable_type', User::class)->get(); foreach ($entitlements as $entitlement) { $entitlement->delete(); } // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::whereIn('wallet_id', $wallets)->get(); $users = []; $domains = []; $groups = []; $entitlements = []; foreach ($assignments as $entitlement) { if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } else { $entitlements[] = $entitlement; } } // Domains/users/entitlements need to be deleted one by one to make sure // events are fired and observers can do the proper cleanup. if (!empty($users)) { foreach (User::whereIn('id', array_unique($users))->get() as $_user) { $_user->delete(); } } if (!empty($domains)) { foreach (Domain::whereIn('id', array_unique($domains))->get() as $_domain) { $_domain->delete(); } } if (!empty($groups)) { foreach (Group::whereIn('id', array_unique($groups))->get() as $_group) { $_group->delete(); } } foreach ($entitlements as $entitlement) { $entitlement->delete(); } // FIXME: What do we do with user wallets? \App\Jobs\User\DeleteJob::dispatch($user->id); } /** * Handle the "deleting" event on forceDelete() call. * * @param User $user The user that is being deleted. * * @return void */ public function forceDeleting(User $user) { // TODO: We assume that at this moment all belongings are already soft-deleted. // Remove owned users/domains $wallets = $user->wallets()->pluck('id')->all(); $assignments = Entitlement::withTrashed()->whereIn('wallet_id', $wallets)->get(); $entitlements = []; $domains = []; $groups = []; $users = []; foreach ($assignments as $entitlement) { $entitlements[] = $entitlement->id; if ($entitlement->entitleable_type == Domain::class) { $domains[] = $entitlement->entitleable_id; } elseif ( $entitlement->entitleable_type == User::class && $entitlement->entitleable_id != $user->id ) { $users[] = $entitlement->entitleable_id; } elseif ($entitlement->entitleable_type == Group::class) { $groups[] = $entitlement->entitleable_id; } } // Remove the user "direct" entitlements explicitely, if they belong to another // user's wallet they will not be removed by the wallets foreign key cascade Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->forceDelete(); // Users need to be deleted one by one to make sure observers can do the proper cleanup. if (!empty($users)) { foreach (User::withTrashed()->whereIn('id', array_unique($users))->get() as $_user) { $_user->forceDelete(); } } // Domains can be just removed if (!empty($domains)) { Domain::withTrashed()->whereIn('id', array_unique($domains))->forceDelete(); } // Groups can be just removed if (!empty($groups)) { Group::withTrashed()->whereIn('id', array_unique($groups))->forceDelete(); } // Remove transactions, they also have no foreign key constraint Transaction::where('object_type', Entitlement::class) ->whereIn('object_id', $entitlements) ->delete(); Transaction::where('object_type', Wallet::class) ->whereIn('object_id', $wallets) ->delete(); } /** * Handle the user "restoring" event. * * @param \App\User $user The user * * @return void */ public function restoring(User $user) { // Make sure it's not DELETED/LDAP_READY/IMAP_READY/SUSPENDED anymore if ($user->isDeleted()) { $user->status ^= User::STATUS_DELETED; } if ($user->isLdapReady()) { $user->status ^= User::STATUS_LDAP_READY; } if ($user->isImapReady()) { $user->status ^= User::STATUS_IMAP_READY; } if ($user->isSuspended()) { $user->status ^= User::STATUS_SUSPENDED; } $user->status |= User::STATUS_ACTIVE; // Note: $user->save() is invoked between 'restoring' and 'restored' events } /** * Handle the user "restored" event. * * @param \App\User $user The user * * @return void */ public function restored(User $user) { $wallets = $user->wallets()->pluck('id')->all(); // Restore user entitlements // We'll restore only these that were deleted last. So, first we get // the maximum deleted_at timestamp and then use it to select // entitlements for restore $deleted_at = \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->max('deleted_at'); if ($deleted_at) { $threshold = (new \Carbon\Carbon($deleted_at))->subMinute(); // We need at least the user domain so it can be created in ldap. // FIXME: What if the domain is owned by someone else? $domain = $user->domain(); if ($domain->trashed() && !$domain->isPublic()) { // Note: Domain entitlements will be restored by the DomainObserver $domain->restore(); } // Restore user entitlements \App\Entitlement::withTrashed() ->where('entitleable_id', $user->id) ->where('entitleable_type', User::class) ->where('deleted_at', '>=', $threshold) ->update(['updated_at' => now(), 'deleted_at' => null]); // Note: We're assuming that cost of entitlements was correct // on user deletion, so we don't have to re-calculate it again. } // FIXME: Should we reset user aliases? or re-validate them in any way? // Create user record in LDAP, then run the verification process $chain = [ new \App\Jobs\User\VerifyJob($user->id), ]; \App\Jobs\User\CreateJob::withChain($chain)->dispatch($user->id); } /** * Handle the "retrieving" event. * * @param User $user The user that is being retrieved. * * @todo This is useful for audit. * * @return void */ public function retrieving(User $user) { // TODO \App\Jobs\User\ReadJob::dispatch($user->id); } /** * Handle the "updating" event. * * @param User $user The user that is being updated. * * @return void */ public function updating(User $user) { \App\Jobs\User\UpdateJob::dispatch($user->id); } } diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php index 839724cc..1297fe1c 100644 --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -1,112 +1,114 @@ {$wallet->getKeyName()} = $allegedly_unique; break; } } + + $wallet->currency = \config('app.currency'); } /** * Handle the wallet "deleting" event. * * Ensures that a wallet with a non-zero balance can not be deleted. * * Ensures that the wallet being deleted is not the last wallet for the user. * * Ensures that no entitlements are being billed to the wallet currently. * * @param Wallet $wallet The wallet being deleted. * * @return bool */ public function deleting(Wallet $wallet): bool { // can't delete a wallet that has any balance on it (positive and negative). if ($wallet->balance != 0.00) { return false; } if (!$wallet->owner) { throw new \Exception("Wallet: " . var_export($wallet, true)); } // can't remove the last wallet for the owner. if ($wallet->owner->wallets()->count() <= 1) { return false; } // can't remove a wallet that has billable entitlements attached. if ($wallet->entitlements()->count() > 0) { return false; } /* // can't remove a wallet that has payments attached. if ($wallet->payments()->count() > 0) { return false; } */ return true; } /** * Handle the wallet "updated" event. * * @param \App\Wallet $wallet The wallet. * * @return void */ public function updated(Wallet $wallet) { $negative_since = $wallet->getSetting('balance_negative_since'); if ($wallet->balance < 0) { if (!$negative_since) { $now = \Carbon\Carbon::now()->toDateTimeString(); $wallet->setSetting('balance_negative_since', $now); } } elseif ($negative_since) { $wallet->setSettings([ 'balance_negative_since' => null, 'balance_warning_initial' => null, 'balance_warning_reminder' => null, 'balance_warning_suspended' => null, 'balance_warning_before_delete' => null, ]); // Unsuspend the account/domains/users if ($wallet->owner) { $wallet->owner->unsuspend(); } foreach ($wallet->entitlements as $entitlement) { if ( $entitlement->entitleable_type == \App\Domain::class || $entitlement->entitleable_type == \App\User::class ) { $entitlement->entitleable->unsuspend(); } } } } } diff --git a/src/app/Payment.php b/src/app/Payment.php index 6d126130..d8a18d35 100644 --- a/src/app/Payment.php +++ b/src/app/Payment.php @@ -1,57 +1,57 @@ 'integer' ]; protected $fillable = [ 'id', 'wallet_id', 'amount', 'description', 'provider', 'status', 'type', 'currency', 'currency_amount', ]; /** * Ensure the currency is appropriately cased. */ public function setCurrencyAttribute($currency) { $this->attributes['currency'] = strtoupper($currency); } /** * The wallet to which this payment belongs. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('\App\Wallet', 'wallet_id', 'id'); } } diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php index 1048b473..7b178699 100644 --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -1,621 +1,626 @@ 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 ); } /** * 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 { // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); if (!isset($payment['amount'])) { $payment['amount'] = 0; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => 'first', 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'redirectUrl' => self::redirectUrl(), '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'] = self::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'] == self::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required (note that JPK and ISK don't require decimals, // but we're not using them currently) 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'redirectUrl' => self::redirectUrl() // required for non-recurring payments ]; // TODO: Additional payment parameters for better fraud protection: // billingEmail - for bank transfers, Przelewy24, but not creditcard // billingAddress (it is a structured field not just text) // Create the payment in Mollie $response = mollie()->payments()->create($request); // Store the payment reference in database $payment['status'] = $response->status; $payment['id'] = $response->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], 'redirectUrl' => $response->getCheckoutUrl(), ]; } /** * Cancel a pending payment. * * @param \App\Wallet $wallet The wallet * @param string $paymentId Payment Id * * @return bool True on success, False on failure */ public function cancel(Wallet $wallet, $paymentId): bool { $response = mollie()->payments()->delete($paymentId); $db_payment = Payment::find($paymentId); $db_payment->status = $response->status; $db_payment->save(); return true; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Operation identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::mollieMandate($wallet); if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { return null; } $customer_id = self::mollieCustomerId($wallet, true); // Note: Required fields: description, amount/currency, amount/value $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', 'method' => $payment['methodId'], 'mandateId' => $mandate->id ]; // 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; } // 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); if (empty($mollie_payment)) { // Mollie recommends to return "200 OK" even if the payment does not exist return 200; } $refunds = []; if ($mollie_payment->isPaid()) { // The payment is paid. Update the balance, and notify the user if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { $credit = true; $notify = $payment->type == self::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' => self::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' => self::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 == self::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 == self::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 = [self::STATUS_OPEN, self::STATUS_PENDING, self::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) { $this->storeRefund($payment->wallet, $refund); } DB::commit(); if (!empty($notify)) { \App\Jobs\PaymentEmail::dispatch($payment); } 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'); // TODO: Localization? $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; $payment->wallet->credit($payment->amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { $payment->wallet->setSetting('mandate_disabled', null); } } /** * 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($type): array + public function providerPaymentMethods(string $type, string $currency): array { - $providerMethods = array_merge( - // Fallback to EUR methods (later provider methods will override earlier ones) - (array) mollie()->methods()->allActive( - [ - 'sequenceType' => $type, - 'amount' => [ - 'value' => '1.00', - 'currency' => 'EUR' - ] + // Prefer methods in the system currency + $providerMethods = (array) mollie()->methods()->allActive( + [ + 'sequenceType' => $type, + 'amount' => [ + 'value' => '1.00', + 'currency' => $currency ] - ), - // Prefer CHF methods - (array) mollie()->methods()->allActive( + ] + ); + + // 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' => 'CHF' + '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('CHF', $method->minimumAmount->currency) + 'exchangeRate' => \App\Utils::exchangeRate($currency, $method->minimumAmount->currency) ]; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { $payment = mollie()->payments()->get($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => $payment->isCancelable, 'checkoutUrl' => $payment->getCheckoutUrl() ]; } } diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php index d96f2af6..460b2777 100644 --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -1,558 +1,559 @@ tag */ public function customerLink(Wallet $wallet): ?string { $customer_id = self::stripeCustomerId($wallet, false); if (!$customer_id) { return null; } $location = 'https://dashboard.stripe.com'; $key = \config('services.stripe.key'); if (strpos($key, 'sk_test_') === 0) { $location .= '/test'; } return sprintf( '%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 (not used) * - 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, true); $request = [ 'customer' => $customer_id, 'cancel_url' => self::redirectUrl(), // required 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'mode' => 'setup', ]; // Note: Stripe does not allow to set amount for 'setup' operation // We'll dispatch WalletCharge job when we receive a webhook request $session = StripeAPI\Checkout\Session::create($request); $payment['amount'] = 0; $payment['currency_amount'] = 0; $payment['id'] = $session->setup_intent; $payment['type'] = self::TYPE_MANDATE; $this->storePayment($payment, $wallet->id); return [ 'id' => $session->id, ]; } /** * Revoke the auto-payment mandate. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ public function deleteMandate(Wallet $wallet): bool { // Get the Mandate info $mandate = self::stripeMandate($wallet); if ($mandate) { // Remove the reference $wallet->setSetting('stripe_mandate_id', null); // Detach the payment method on Stripe $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); $pm->detach(); } return true; } /** * Get a auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ public function getMandate(Wallet $wallet): ?array { // Get the Mandate info $mandate = self::stripeMandate($wallet); if (empty($mandate)) { return null; } $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); $result = [ 'id' => $mandate->id, 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', 'isValid' => $mandate->status == 'succeeded', 'method' => self::paymentMethod($pm, 'Unknown method') ]; return $result; } /** * Get a provider name * * @return string Provider name */ public function name(): string { return 'stripe'; } /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: first/oneoff/recurring * - description: Operation desc. * * @return array Provider payment/session data: * - id: Session identifier */ public function payment(Wallet $wallet, array $payment): ?array { if ($payment['type'] == self::TYPE_RECURRING) { return $this->paymentRecurring($wallet, $payment); } // Register the user in Stripe, if not yet done $customer_id = self::stripeCustomerId($wallet, true); $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'customer' => $customer_id, 'cancel_url' => self::redirectUrl(), // required 'success_url' => self::redirectUrl(), // required 'payment_method_types' => ['card'], // required 'locale' => 'en', 'line_items' => [ [ 'name' => $payment['description'], 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'quantity' => 1, ] ] ]; $session = StripeAPI\Checkout\Session::create($request); // Store the payment reference in database $payment['id'] = $session->payment_intent; $this->storePayment($payment, $wallet->id); return [ 'id' => $session->id, ]; } /** * Create a new automatic payment operation. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data (see self::payment()) * * @return array Provider payment/session data: * - id: Session identifier */ protected function paymentRecurring(Wallet $wallet, array $payment): ?array { // Check if there's a valid mandate $mandate = self::stripeMandate($wallet); if (empty($mandate)) { return null; } $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); $payment['currency_amount'] = $amount; $request = [ 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'description' => $payment['description'], 'receipt_email' => $wallet->owner->email, 'customer' => $mandate->customer, 'payment_method' => $mandate->payment_method, 'off_session' => true, 'confirm' => true, ]; $intent = StripeAPI\PaymentIntent::create($request); // Store the payment reference in database $payment['id'] = $intent->id; $this->storePayment($payment, $wallet->id); return [ 'id' => $payment['id'], ]; } /** * Update payment status (and balance). * * @return int HTTP response code */ public function webhook(): int { // We cannot just use php://input as it's already "emptied" by the framework // $payload = file_get_contents('php://input'); $request = Request::instance(); $payload = $request->getContent(); $sig_header = $request->header('Stripe-Signature'); // Parse and validate the input try { $event = StripeAPI\Webhook::constructEvent( $payload, $sig_header, \config('services.stripe.webhook_secret') ); } catch (\Exception $e) { \Log::error("Invalid payload: " . $e->getMessage()); // Invalid payload return 400; } switch ($event->type) { case StripeAPI\Event::PAYMENT_INTENT_CANCELED: case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED: case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED: $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); if (empty($payment) || $payment->type == self::TYPE_MANDATE) { return 404; } 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_FAILED; } DB::beginTransaction(); if ($status == self::STATUS_PAID) { // Update the balance, if it wasn't already if ($payment->status != self::STATUS_PAID) { $this->creditPayment($payment, $intent); } } else { if (!empty($intent->last_payment_error)) { // See https://stripe.com/docs/error-codes for more info \Log::info(sprintf( 'Stripe payment failed (%s): %s', $payment->id, json_encode($intent->last_payment_error) )); } } if ($payment->status != self::STATUS_PAID) { $payment->status = $status; $payment->save(); if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) { // Disable the mandate if ($status == self::STATUS_FAILED) { $payment->wallet->setSetting('mandate_disabled', 1); } // Notify the user \App\Jobs\PaymentEmail::dispatch($payment); } } DB::commit(); break; case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED: case StripeAPI\Event::SETUP_INTENT_CANCELED: $intent = $event->data->object; // @phpstan-ignore-line $payment = Payment::find($intent->id); if (empty($payment) || $payment->type != self::TYPE_MANDATE) { return 404; } switch ($intent->status) { case StripeAPI\SetupIntent::STATUS_CANCELED: $status = self::STATUS_CANCELED; break; case StripeAPI\SetupIntent::STATUS_SUCCEEDED: $status = self::STATUS_PAID; break; default: $status = self::STATUS_FAILED; } if ($status == self::STATUS_PAID) { $payment->wallet->setSetting('stripe_mandate_id', $intent->id); $threshold = intval((float) $payment->wallet->getSetting('mandate_balance') * 100); // Top-up the wallet if balance is below the threshold if ($payment->wallet->balance < $threshold && $payment->status != self::STATUS_PAID) { \App\Jobs\WalletCharge::dispatch($payment->wallet); } } $payment->status = $status; $payment->save(); break; default: \Log::debug("Unhandled Stripe event: " . var_export($payload, true)); break; } return 200; } /** * Get Stripe customer identifier for specified wallet. * Create one if does not exist yet. * * @param \App\Wallet $wallet The wallet * @param bool $create Create the customer if does not exist yet * * @return string|null Stripe customer identifier */ protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string { $customer_id = $wallet->getSetting('stripe_id'); // Register the user in Stripe if (empty($customer_id) && $create) { $customer = StripeAPI\Customer::create([ 'name' => $wallet->owner->name(), // Stripe will display the email on Checkout page, editable, // and use it to send the receipt (?), use the user email here // 'email' => $wallet->id . '@private.' . \config('app.domain'), 'email' => $wallet->owner->email, ]); $customer_id = $customer->id; $wallet->setSetting('stripe_id', $customer->id); } return $customer_id; } /** * Get the active Stripe auto-payment mandate (Setup Intent) */ protected static function stripeMandate(Wallet $wallet) { // Note: Stripe also has 'Mandate' objects, but we do not use these if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { $mandate = StripeAPI\SetupIntent::retrieve($mandate_id); // @phpstan-ignore-next-line if ($mandate && $mandate->status != 'canceled') { return $mandate; } } } /** * Apply the successful payment's pecunia to the wallet */ protected static function creditPayment(Payment $payment, $intent) { $method = 'Stripe'; // Extract the payment method for transaction description if ( !empty($intent->charges) && ($charge = $intent->charges->data[0]) && ($pm = $charge->payment_method_details) ) { $method = self::paymentMethod($pm); } // TODO: Localization? $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; $payment->wallet->credit($payment->amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { $payment->wallet->setSetting('mandate_disabled', null); } } /** * Extract payment method description from Stripe payment details */ protected static function paymentMethod($details, $default = ''): string { switch ($details->type) { case 'card': // TODO: card number return \sprintf( '%s (**** **** **** %s)', \ucfirst($details->card->brand) ?: 'Card', $details->card->last4 ); } return $default; } /** * List supported payment methods. * - * @param string $type The payment type for which we require a method (oneoff/recurring). + * @param string $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($type): array + public function providerPaymentMethods(string $type, string $currency): array { //TODO get this from the stripe API? $availableMethods = []; switch ($type) { case self::TYPE_ONEOFF: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => self::MIN_AMOUNT, - 'currency' => 'CHF', + 'currency' => $currency, 'exchangeRate' => 1.0 ], self::METHOD_PAYPAL => [ 'id' => self::METHOD_PAYPAL, 'name' => "PayPal", 'minimumAmount' => self::MIN_AMOUNT, - 'currency' => 'CHF', + 'currency' => $currency, 'exchangeRate' => 1.0 ] ]; break; case self::TYPE_RECURRING: $availableMethods = [ self::METHOD_CREDITCARD => [ 'id' => self::METHOD_CREDITCARD, 'name' => "Credit Card", 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents, - 'currency' => 'CHF', + 'currency' => $currency, 'exchangeRate' => 1.0 ] ]; break; } return $availableMethods; } /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ public function getPayment($paymentId): array { \Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl."); $payment = StripeAPI\PaymentIntent::retrieve($paymentId); return [ 'id' => $payment->id, 'status' => $payment->status, 'isCancelable' => false, 'checkoutUrl' => null ]; } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php index dc21e88b..5d65329c 100644 --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -1,382 +1,384 @@ ['prefix' => 'far', 'name' => 'credit-card'], self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'], self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university'] ]; /** * Detect the name of the provider * * @param \App\Wallet|string|null $provider_or_wallet * @return string The name of the provider */ private static function providerName($provider_or_wallet = null): string { if ($provider_or_wallet instanceof Wallet) { $settings = $provider_or_wallet->getSettings(['stripe_id', 'mollie_id']); if ($settings['stripe_id']) { $provider = self::PROVIDER_STRIPE; } elseif ($settings['mollie_id']) { $provider = self::PROVIDER_MOLLIE; } } else { $provider = $provider_or_wallet; } if (empty($provider)) { $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE; } return \strtolower($provider); } /** * Factory method * * @param \App\Wallet|string|null $provider_or_wallet */ public static function factory($provider_or_wallet = null) { switch (self::providerName($provider_or_wallet)) { case self::PROVIDER_STRIPE: return new \App\Providers\Payment\Stripe(); case self::PROVIDER_MOLLIE: return new \App\Providers\Payment\Mollie(); default: throw new \Exception("Invalid payment provider: {$provider_or_wallet}"); } } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ abstract public function createMandate(Wallet $wallet, array $payment): ?array; /** * Revoke the auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ abstract public function deleteMandate(Wallet $wallet): bool; /** * Get a auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ abstract public function getMandate(Wallet $wallet): ?array; /** * Get a link to the customer in the provider's control panel * * @param \App\Wallet $wallet The wallet * * @return string|null The string representing 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 * - methodId: Payment method * * @return array Provider payment/session data: * - id: Operation identifier * - redirectUrl */ abstract public function payment(Wallet $wallet, array $payment): ?array; /** * Update payment status (and balance). * * @return int HTTP response code */ abstract public function webhook(): int; /** * Create a payment record in DB * * @param array $payment Payment information * @param string $wallet_id Wallet ID * * @return \App\Payment Payment object */ protected function storePayment(array $payment, $wallet_id): Payment { $db_payment = new Payment(); $db_payment->id = $payment['id']; $db_payment->description = $payment['description'] ?? ''; $db_payment->status = $payment['status'] ?? self::STATUS_OPEN; $db_payment->amount = $payment['amount'] ?? 0; $db_payment->type = $payment['type']; $db_payment->wallet_id = $wallet_id; $db_payment->provider = $this->name(); $db_payment->currency = $payment['currency']; $db_payment->currency_amount = $payment['currency_amount']; $db_payment->save(); return $db_payment; } /** * Convert a value from $sourceCurrency to $targetCurrency * * @param int $amount Amount in cents of $sourceCurrency * @param string $sourceCurrency Currency from which to convert * @param string $targetCurrency Currency to convert to * * @return int Exchanged amount in cents of $targetCurrency */ protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int { return intval(round($amount * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency))); } /** * Deduct an amount of pecunia from the wallet. * Creates a payment and transaction records for the refund/chargeback operation. * * @param \App\Wallet $wallet A wallet object * @param array $refund A refund or chargeback data (id, type, amount, description) * * @return void */ protected function storeRefund(Wallet $wallet, array $refund): void { if (empty($refund) || empty($refund['amount'])) { return; } // Preserve originally refunded amount $refund['currency_amount'] = $refund['amount'] * -1; // Convert amount to wallet currency // TODO We should possibly be using the same exchange rate as for the original payment? $amount = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency); $wallet->balance -= $amount; $wallet->save(); if ($refund['type'] == self::TYPE_CHARGEBACK) { $transaction_type = Transaction::WALLET_CHARGEBACK; } else { $transaction_type = Transaction::WALLET_REFUND; } Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $transaction_type, 'amount' => $amount * -1, 'description' => $refund['description'] ?? '', ]); $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; // FIXME: Refunds/chargebacks are out of the reseller comissioning for now $this->storePayment($refund, $wallet->id); } /** * List supported payment methods from this provider * - * @param string $type The payment type for which we require a method (oneoff/recurring). + * @param string $type The payment type for which we require a method (oneoff/recurring). + * @param string $currency Currency code * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ - abstract public function providerPaymentMethods($type): array; + abstract public function providerPaymentMethods(string $type, string $currency): array; /** * Get a payment. * * @param string $paymentId Payment identifier * * @return array Payment information: * - id: Payment identifier * - status: Payment status * - isCancelable: The payment can be canceled * - checkoutUrl: The checkout url to complete the payment or null if none */ abstract public function getPayment($paymentId): array; /** * Return an array of whitelisted payment methods with override values. * * @param string $type The payment type for which we require a method. * * @return array Array of methods */ protected static function paymentMethodsWhitelist($type): array { $methods = []; switch ($type) { case self::TYPE_ONEOFF: $methods = explode(',', \config('app.payment.methods_oneoff')); break; case PaymentProvider::TYPE_RECURRING: $methods = explode(',', \config('app.payment.methods_recurring')); break; default: \Log::error("Unknown payment type: " . $type); } $methods = array_map('strtolower', array_map('trim', $methods)); return $methods; } /** * Return an array of whitelisted payment methods with override values. * * @param string $type The payment type for which we require a method. * * @return array Array of methods */ private static function applyMethodWhitelist($type, $availableMethods): array { $methods = []; // Use only whitelisted methods, and apply values from whitelist (overriding the backend) $whitelistMethods = self::paymentMethodsWhitelist($type); foreach ($whitelistMethods as $id) { if (array_key_exists($id, $availableMethods)) { $method = $availableMethods[$id]; $method['icon'] = self::$paymentMethodIcons[$id]; $methods[] = $method; } } return $methods; } /** * List supported payment methods for $wallet * * @param \App\Wallet $wallet The wallet * @param string $type The payment type for which we require a method (oneoff/recurring). * * @return array Array of array with available payment methods: * - id: id of the method * - name: User readable name of the payment method * - minimumAmount: Minimum amount to be charged in cents * - currency: Currency used for the method * - exchangeRate: The projected exchange rate (actual rate is determined during payment) * - icon: An icon (icon name) representing the method */ public static function paymentMethods(Wallet $wallet, $type): array { $providerName = self::providerName($wallet); - $cacheKey = "methods-" . $providerName . '-' . $type; + $cacheKey = "methods-{$providerName}-{$type}-{$wallet->currency}"; if ($methods = Cache::get($cacheKey)) { \Log::debug("Using payment method cache" . var_export($methods, true)); return $methods; } $provider = PaymentProvider::factory($providerName); - $methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type)); + $methods = $provider->providerPaymentMethods($type, $wallet->currency); + $methods = self::applyMethodWhitelist($type, $methods); \Log::debug("Loaded payment methods" . var_export($methods, true)); Cache::put($cacheKey, $methods, now()->addHours(1)); return $methods; } /** * Returns the full URL for the wallet page, used when returning from an external payment page. * Depending on the request origin it will return a URL for the User or Reseller UI. * * @return string The redirect URL */ public static function redirectUrl(): string { $url = \App\Utils::serviceUrl('/wallet'); $domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); if (strpos($domain, 'reseller') === 0) { $url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url); } return $url; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 882dedec..d2ddbcf0 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,417 +1,437 @@ 0, - 'currency' => 'CHF' ]; + /** + * The attributes that are mass assignable. + * + * @var array + */ protected $fillable = [ - 'currency' + 'currency', + 'description' ]; + /** + * The attributes that can be not set. + * + * @var array + */ protected $nullable = [ 'description', ]; + /** + * The types of attributes to which its values will be cast + * + * @var array + */ 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) { // This wallet has been created less than a month ago, this is the trial period if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) { // Move all the current entitlement's updated_at timestamps forward to one month after // this wallet was created. $freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1); foreach ($this->entitlements()->get()->fresh() as $entitlement) { if ($entitlement->updated_at < $freeMonthEnds) { $entitlement->updated_at = $freeMonthEnds; $entitlement->save(); } } return 0; } $profit = 0; $charges = 0; $discount = $this->getDiscountRate(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; 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; } // updated last 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); $fee = (int) ($entitlement->fee * $diff); $charges += $cost; $profit += $cost - $fee; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy() ->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( \App\Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, '', $entitlementTransactions); // Credit/debit the reseller if ($profit != 0 && $this->owner->tenant) { // FIXME: Should we have a simpler way to skip this for non-reseller tenant(s) if ($wallet = $this->owner->tenant->wallet()) { $desc = "Charged user {$this->owner->email}"; $method = $profit > 0 ? 'credit' : 'debit'; $wallet->{$method}(abs($profit), $desc); } } } DB::commit(); return $charges; } /** * Calculate for how long the current balance will last. * * Returns NULL for balance < 0 or discount = 100% or on a fresh account * * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { if ($this->balance < 0 || $this->getDiscount() == 100) { return null; } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); if (!$costsPerDay) { return null; } // the number of days this balance, minus the expected charges, would last $daysDelta = ($this->balance - $expectedCharge) / $costsPerDay; // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); $until = $entitlement->updated_at->copy()->addDays($daysDelta); // Don't return dates from the past if ($until < Carbon::now() && !$until->isToday()) { return null; } return $until; } /** * 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 ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @param string $description The transaction description * @param array $eTIDs List of transaction IDs for the individual entitlements * that make up this debit record, if any. * @return Wallet Self */ public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, 'amount' => $amount * -1, 'description' => $description ] ); if (!empty($eTIDs)) { \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); } return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } /** * A helper to display human-readable amount of money using * the wallet currency and specified locale. * * @param int $amount A amount of money (in cents) * @param string $locale A locale for the output * * @return string String representation, e.g. "9.99 CHF" */ public function money(int $amount, $locale = 'de_DE') { $amount = round($amount / 100, 2); - // Prefer intl extension's number formatter - if (class_exists('NumberFormatter')) { - $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); - $result = $nf->formatCurrency($amount, $this->currency); - // Replace non-breaking space - return str_replace("\xC2\xA0", " ", $result); - } - - return sprintf('%.2f %s', $amount, $this->currency); + $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); + $result = $nf->formatCurrency($amount, $this->currency); + // Replace non-breaking space + return str_replace("\xC2\xA0", " ", $result); } /** * 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'); } /** * 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); } } /** * Any (additional) properties of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\WalletSetting'); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return \App\Transaction::where( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class ] ); } } diff --git a/src/config/app.php b/src/config/app.php index e4125ac6..c175dc57 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -1,284 +1,286 @@ env('APP_NAME', 'Laravel'), /* |-------------------------------------------------------------------------- | Application Environment |-------------------------------------------------------------------------- | | This value determines the "environment" your application is currently | running in. This may determine how you prefer to configure various | services the application utilizes. Set this in your ".env" file. | */ 'env' => env('APP_ENV', 'production'), /* |-------------------------------------------------------------------------- | Application Debug Mode |-------------------------------------------------------------------------- | | When your application is in debug mode, detailed error messages with | stack traces will be shown on every error that occurs within your | application. If disabled, a simple generic error page is shown. | */ 'debug' => env('APP_DEBUG', false), /* |-------------------------------------------------------------------------- | Application URL |-------------------------------------------------------------------------- | | This URL is used by the console to properly generate URLs when using | the Artisan command line tool. You should set this to the root of | your application so that it is used when running Artisan tasks. */ 'url' => env('APP_URL', 'http://localhost'), 'passphrase' => env('APP_PASSPHRASE', null), 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), 'asset_url' => env('ASSET_URL', null), 'support_url' => env('SUPPORT_URL', null), 'support_email' => env('SUPPORT_EMAIL', null), 'webmail_url' => env('WEBMAIL_URL', null), 'theme' => env('APP_THEME', 'default'), 'tenant_id' => env('APP_TENANT_ID', null), + 'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')), + /* |-------------------------------------------------------------------------- | Application Domain |-------------------------------------------------------------------------- | | System domain used for user signup (kolab identity) */ 'domain' => env('APP_DOMAIN', 'domain.tld'), 'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')), /* |-------------------------------------------------------------------------- | Application Timezone |-------------------------------------------------------------------------- | | Here you may specify the default timezone for your application, which | will be used by the PHP date and date-time functions. We have gone | ahead and set this to a sensible default for you out of the box. | */ 'timezone' => 'UTC', /* |-------------------------------------------------------------------------- | Application Locale Configuration |-------------------------------------------------------------------------- | | The application locale determines the default locale that will be used | by the translation service provider. You are free to set this value | to any of the locales which will be supported by the application. | */ 'locale' => env('APP_LOCALE', 'en'), /* |-------------------------------------------------------------------------- | Application Fallback Locale |-------------------------------------------------------------------------- | | The fallback locale determines the locale to use when the current one | is not available. You may change the value to correspond to any of | the language folders that are provided through your application. | */ 'fallback_locale' => 'en', /* |-------------------------------------------------------------------------- | Faker Locale |-------------------------------------------------------------------------- | | This locale will be used by the Faker PHP library when generating fake | data for your database seeds. For example, this will be used to get | localized telephone numbers, street address information and more. | */ 'faker_locale' => 'en_US', /* |-------------------------------------------------------------------------- | Encryption Key |-------------------------------------------------------------------------- | | This key is used by the Illuminate encrypter service and should be set | to a random, 32 character string, otherwise these encrypted strings | will not be safe. Please do this before deploying an application! | */ 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', /* |-------------------------------------------------------------------------- | Autoloaded Service Providers |-------------------------------------------------------------------------- | | The service providers listed here will be automatically loaded on the | request to your application. Feel free to add your own services to | this array to grant expanded functionality to your applications. | */ 'providers' => [ /* * Laravel Framework Service Providers... */ Illuminate\Auth\AuthServiceProvider::class, Illuminate\Broadcasting\BroadcastServiceProvider::class, Illuminate\Bus\BusServiceProvider::class, Illuminate\Cache\CacheServiceProvider::class, Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, Illuminate\Cookie\CookieServiceProvider::class, Illuminate\Database\DatabaseServiceProvider::class, Illuminate\Encryption\EncryptionServiceProvider::class, Illuminate\Filesystem\FilesystemServiceProvider::class, Illuminate\Foundation\Providers\FoundationServiceProvider::class, Illuminate\Hashing\HashServiceProvider::class, Illuminate\Mail\MailServiceProvider::class, Illuminate\Notifications\NotificationServiceProvider::class, Illuminate\Pagination\PaginationServiceProvider::class, Illuminate\Pipeline\PipelineServiceProvider::class, Illuminate\Queue\QueueServiceProvider::class, Illuminate\Redis\RedisServiceProvider::class, Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, Illuminate\Session\SessionServiceProvider::class, Illuminate\Translation\TranslationServiceProvider::class, Illuminate\Validation\ValidationServiceProvider::class, Illuminate\View\ViewServiceProvider::class, /* * Package Service Providers... */ Barryvdh\DomPDF\ServiceProvider::class, /* * Application Service Providers... */ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, // App\Providers\BroadcastServiceProvider::class, App\Providers\EventServiceProvider::class, App\Providers\HorizonServiceProvider::class, App\Providers\RouteServiceProvider::class, ], /* |-------------------------------------------------------------------------- | Class Aliases |-------------------------------------------------------------------------- | | This array of class aliases will be registered when this application | is started. However, feel free to register as many as you wish as | the aliases are "lazy" loaded so they don't hinder performance. | */ 'aliases' => [ 'App' => Illuminate\Support\Facades\App::class, 'Arr' => Illuminate\Support\Arr::class, 'Artisan' => Illuminate\Support\Facades\Artisan::class, 'Auth' => Illuminate\Support\Facades\Auth::class, 'Blade' => Illuminate\Support\Facades\Blade::class, 'Broadcast' => Illuminate\Support\Facades\Broadcast::class, 'Bus' => Illuminate\Support\Facades\Bus::class, 'Cache' => Illuminate\Support\Facades\Cache::class, 'Config' => Illuminate\Support\Facades\Config::class, 'Cookie' => Illuminate\Support\Facades\Cookie::class, 'Crypt' => Illuminate\Support\Facades\Crypt::class, 'DB' => Illuminate\Support\Facades\DB::class, 'Eloquent' => Illuminate\Database\Eloquent\Model::class, 'Event' => Illuminate\Support\Facades\Event::class, 'File' => Illuminate\Support\Facades\File::class, 'Gate' => Illuminate\Support\Facades\Gate::class, 'Hash' => Illuminate\Support\Facades\Hash::class, 'Lang' => Illuminate\Support\Facades\Lang::class, 'Log' => Illuminate\Support\Facades\Log::class, 'Mail' => Illuminate\Support\Facades\Mail::class, 'Notification' => Illuminate\Support\Facades\Notification::class, 'Password' => Illuminate\Support\Facades\Password::class, 'PDF' => Barryvdh\DomPDF\Facade::class, 'Queue' => Illuminate\Support\Facades\Queue::class, 'Redirect' => Illuminate\Support\Facades\Redirect::class, 'Redis' => Illuminate\Support\Facades\Redis::class, 'Request' => Illuminate\Support\Facades\Request::class, 'Response' => Illuminate\Support\Facades\Response::class, 'Route' => Illuminate\Support\Facades\Route::class, 'Schema' => Illuminate\Support\Facades\Schema::class, 'Session' => Illuminate\Support\Facades\Session::class, 'Storage' => Illuminate\Support\Facades\Storage::class, 'Str' => Illuminate\Support\Str::class, 'URL' => Illuminate\Support\Facades\URL::class, 'Validator' => Illuminate\Support\Facades\Validator::class, 'View' => Illuminate\Support\Facades\View::class, ], // Locations of knowledge base articles 'kb' => [ // An article about suspended accounts 'account_suspended' => env('KB_ACCOUNT_SUSPENDED'), // An article about a way to delete an owned account 'account_delete' => env('KB_ACCOUNT_DELETE'), ], 'company' => [ 'name' => env('COMPANY_NAME'), 'address' => env('COMPANY_ADDRESS'), 'details' => env('COMPANY_DETAILS'), 'email' => env('COMPANY_EMAIL'), 'logo' => env('COMPANY_LOGO'), 'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')), ], 'vat' => [ 'countries' => env('VAT_COUNTRIES'), 'rate' => (float) env('VAT_RATE'), ], 'payment' => [ 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', "creditcard,paypal,banktransfer"), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', "creditcard"), ], 'with_admin' => (bool) env('APP_WITH_ADMIN', false), 'with_reseller' => (bool) env('APP_WITH_RESELLER', false), 'with_services' => (bool) env('APP_WITH_SERVICES', false), ]; diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php index 73ab2599..5d983c0b 100644 --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -1,419 +1,419 @@ [ 'faq' => "FAQ", ], 'btn' => [ 'add' => "Add", 'accept' => "Accept", 'back' => "Back", 'cancel' => "Cancel", 'close' => "Close", 'continue' => "Continue", 'delete' => "Delete", 'deny' => "Deny", 'download' => "Download", 'edit' => "Edit", 'file' => "Choose file...", 'moreinfo' => "More information", 'refresh' => "Refresh", 'reset' => "Reset", 'resend' => "Resend", 'save' => "Save", 'search' => "Search", 'signup' => "Sign Up", 'submit' => "Submit", 'suspend' => "Suspend", 'unsuspend' => "Unsuspend", 'verify' => "Verify", ], 'dashboard' => [ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", 'users' => "User accounts", 'wallet' => "Wallet", 'webmail' => "Webmail", 'stats' => "Stats", ], 'distlist' => [ 'list-title' => "Distribution list | Distribution lists", 'create' => "Create list", 'delete' => "Delete list", 'email' => "Email", 'list-empty' => "There are no distribution lists in this account.", 'new' => "New distribution list", 'recipients' => "Recipients", ], 'domain' => [ 'dns-verify' => "Domain DNS verification sample:", 'dns-config' => "Domain DNS configuration sample:", 'namespace' => "Namespace", 'spf-whitelist' => "SPF Whitelist", 'spf-whitelist-text' => "The Sender Policy Framework allows a sender domain to disclose, through DNS, " . "which systems are allowed to send emails with an envelope sender address within said domain.", 'spf-whitelist-ex' => "Here you can specify a list of allowed servers, for example: .ess.barracuda.com.", 'verify' => "Domain verification", 'verify-intro' => "In order to confirm that you're the actual holder of the domain, we need to run a verification process before finally activating it for email delivery.", 'verify-dns' => "The domain must have one of the following entries in DNS:", 'verify-dns-txt' => "TXT entry with value:", 'verify-dns-cname' => "or CNAME entry:", 'verify-outro' => "When this is done press the button below to start the verification.", 'verify-sample' => "Here's a sample zone file for your domain:", 'config' => "Domain configuration", 'config-intro' => "In order to let {app} receive email traffic for your domain you need to adjust the DNS settings, more precisely the MX entries, accordingly.", 'config-sample' => "Edit your domain's zone file and replace existing MX entries with the following values:", 'config-hint' => "If you don't know how to set DNS entries for your domain, please contact the registration service where you registered the domain or your web hosting provider.", ], 'error' => [ '400' => "Bad request", '401' => "Unauthorized", '403' => "Access denied", '404' => "Not found", '405' => "Method not allowed", '500' => "Internal server error", 'unknown' => "Unknown Error", 'server' => "Server Error", ], 'form' => [ 'amount' => "Amount", 'code' => "Confirmation Code", 'config' => "Configuration", 'date' => "Date", 'description' => "Description", 'details' => "Details", 'disabled' => "disabled", 'domain' => "Domain", 'email' => "Email Address", 'enabled' => "enabled", 'firstname' => "First Name", 'general' => "General", 'lastname' => "Last Name", 'none' => "none", 'or' => "or", 'password' => "Password", 'password-confirm' => "Confirm Password", 'phone' => "Phone", 'settings' => "Settings", 'status' => "Status", 'surname' => "Surname", 'user' => "User", 'primary-email' => "Primary Email", 'id' => "ID", 'created' => "Created", 'deleted' => "Deleted", ], 'invitation' => [ 'create' => "Create invite(s)", 'create-title' => "Invite for a signup", 'create-email' => "Enter an email address of the person you want to invite.", 'create-csv' => "To send multiple invitations at once, provide a CSV (comma separated) file, or alternatively a plain-text file, containing one email address per line.", 'empty-list' => "There are no invitations in the database.", 'title' => "Signup invitations", 'search' => "Email address or domain", 'send' => "Send invite(s)", 'status-completed' => "User signed up", 'status-failed' => "Sending failed", 'status-sent' => "Sent", 'status-new' => "Not sent yet", ], 'lang' => [ 'en' => "English", 'de' => "German", 'fr' => "French", 'it' => "Italian", ], 'login' => [ '2fa' => "Second factor code", '2fa_desc' => "Second factor code is optional for users with no 2-Factor Authentication setup.", 'forgot_password' => "Forgot password?", 'header' => "Please sign in", 'sign_in' => "Sign in", 'webmail' => "Webmail" ], 'meet' => [ 'title' => "Voice & Video Conferencing", 'welcome' => "Welcome to our beta program for Voice & Video Conferencing.", 'url' => "You have a room of your own at the URL below. This room is only open when you yourself are in attendance. Use this URL to invite people to join you.", 'notice' => "This is a work in progress and more features will be added over time. Current features include:", 'sharing' => "Screen Sharing", 'sharing-text' => "Share your screen for presentations or show-and-tell.", 'security' => "Room Security", 'security-text' => "Increase the room security by setting a password that attendees will need to know" . " before they can enter, or lock the door so attendees will have to knock, and a moderator can accept or deny those requests.", 'qa' => "Raise Hand (Q&A)", 'qa-text' => "Silent audience members can raise their hand to facilitate a Question & Answer session with the panel members.", 'moderation' => "Moderator Delegation", 'moderation-text' => "Delegate moderator authority for the session, so that a speaker is not needlessly" . " interrupted with attendees knocking and other moderator duties.", 'eject' => "Eject Attendees", 'eject-text' => "Eject attendees from the session in order to force them to reconnect, or address policy" . " violations. Click the user icon for effective dismissal.", 'silent' => "Silent Audience Members", 'silent-text' => "For a webinar-style session, configure the room to force all new attendees to be silent audience members.", 'interpreters' => "Language Specific Audio Channels", 'interpreters-text' => "Designate a participant to interpret the original audio to a target language, for sessions" . " with multi-lingual attendees. The interpreter is expected to be able to relay the original audio, and override it.", 'beta-notice' => "Keep in mind that this is still in beta and might come with some issues." . " Should you encounter any on your way, let us know by contacting support.", // Room options dialog 'options' => "Room options", 'password' => "Password", 'password-none' => "none", 'password-clear' => "Clear password", 'password-set' => "Set password", 'password-text' => "You can add a password to your meeting. Participants will have to provide the password before they are allowed to join the meeting.", 'lock' => "Locked room", 'lock-text' => "When the room is locked participants have to be approved by a moderator before they could join the meeting.", 'nomedia' => "Subscribers only", 'nomedia-text' => "Forces all participants to join as subscribers (with camera and microphone turned off)." . " Moderators will be able to promote them to publishers throughout the session.", // Room menu 'partcnt' => "Number of participants", 'menu-audio-mute' => "Mute audio", 'menu-audio-unmute' => "Unmute audio", 'menu-video-mute' => "Mute video", 'menu-video-unmute' => "Unmute video", 'menu-screen' => "Share screen", 'menu-hand-lower' => "Lower hand", 'menu-hand-raise' => "Raise hand", 'menu-channel' => "Interpreted language channel", 'menu-chat' => "Chat", 'menu-fullscreen' => "Full screen", 'menu-fullscreen-exit' => "Exit full screen", 'menu-leave' => "Leave session", // Room setup screen 'setup-title' => "Set up your session", 'mic' => "Microphone", 'cam' => "Camera", 'nick' => "Nickname", 'nick-placeholder' => "Your name", 'join' => "JOIN", 'joinnow' => "JOIN NOW", 'imaowner' => "I'm the owner", // Room 'qa' => "Q & A", 'leave-title' => "Room closed", 'leave-body' => "The session has been closed by the room owner.", 'media-title' => "Media setup", 'join-request' => "Join request", 'join-requested' => "{user} requested to join.", // Status messages 'status-init' => "Checking the room...", 'status-323' => "The room is closed. Please, wait for the owner to start the session.", 'status-324' => "The room is closed. It will be open for others after you join.", 'status-325' => "The room is ready. Please, provide a valid password.", 'status-326' => "The room is locked. Please, enter your name and try again.", 'status-327' => "Waiting for permission to join the room.", 'status-404' => "The room does not exist.", 'status-429' => "Too many requests. Please, wait.", 'status-500' => "Failed to connect to the room. Server error.", // Other menus 'media-setup' => "Media setup", 'perm' => "Permissions", 'perm-av' => "Audio & Video publishing", 'perm-mod' => "Moderation", 'lang-int' => "Language interpreter", 'menu-options' => "Options", ], 'menu' => [ 'cockpit' => "Cockpit", 'login' => "Login", 'logout' => "Logout", 'signup' => "Signup", 'toggle' => "Toggle navigation", ], 'msg' => [ 'initializing' => "Initializing...", 'loading' => "Loading...", 'loading-failed' => "Failed to load data.", 'notfound' => "Resource not found.", 'info' => "Information", 'error' => "Error", 'warning' => "Warning", 'success' => "Success", ], 'nav' => [ 'more' => "Load more", 'step' => "Step {i}/{n}", ], 'password' => [ 'reset' => "Password Reset", 'reset-step1' => "Enter your email address to reset your password.", 'reset-step1-hint' => "You may need to check your spam folder or unblock {email}.", 'reset-step2' => "We sent out a confirmation code to your external email address." . " Enter the code we sent you, or click the link in the message.", ], 'signup' => [ 'email' => "Existing Email Address", 'login' => "Login", 'title' => "Sign Up", 'step1' => "Sign up to start your free month.", 'step2' => "We sent out a confirmation code to your email address. Enter the code we sent you, or click the link in the message.", 'step3' => "Create your Kolab identity (you can choose additional addresses later).", 'voucher' => "Voucher Code", ], 'status' => [ 'prepare-account' => "We are preparing your account.", 'prepare-domain' => "We are preparing the domain.", 'prepare-distlist' => "We are preparing the distribution list.", 'prepare-user' => "We are preparing the user account.", 'prepare-hint' => "Some features may be missing or readonly at the moment.", 'prepare-refresh' => "The process never ends? Press the \"Refresh\" button, please.", 'ready-account' => "Your account is almost ready.", 'ready-domain' => "The domain is almost ready.", 'ready-distlist' => "The distribution list is almost ready.", 'ready-user' => "The user account is almost ready.", 'verify' => "Verify your domain to finish the setup process.", 'verify-domain' => "Verify domain", 'deleted' => "Deleted", 'suspended' => "Suspended", 'notready' => "Not Ready", 'active' => "Active", ], 'support' => [ 'title' => "Contact Support", 'id' => "Customer number or email address you have with us", 'id-pl' => "e.g. 12345678 or john@kolab.org", 'id-hint' => "Leave blank if you are not a customer yet", 'name' => "Name", 'name-pl' => "how we should call you in our reply", 'email' => "Working email address", 'email-pl' => "make sure we can reach you at this address", 'summary' => "Issue Summary", 'summary-pl' => "one sentence that summarizes your issue", 'expl' => "Issue Explanation", ], 'user' => [ '2fa-hint1' => "This will remove 2-Factor Authentication entitlement as well as the user-configured factors.", '2fa-hint2' => "Please, make sure to confirm the user identity properly.", 'address' => "Address", 'aliases' => "Aliases", 'aliases-email' => "Email Aliases", 'aliases-none' => "This user has no email aliases.", 'add-bonus' => "Add bonus", 'add-bonus-title' => "Add a bonus to the wallet", 'add-penalty' => "Add penalty", 'add-penalty-title' => "Add a penalty to the wallet", 'auto-payment' => "Auto-payment", - 'auto-payment-text' => "Fill up by {amount} CHF when under {balance} CHF using {method}", + 'auto-payment-text' => "Fill up by {amount} when under {balance} using {method}", 'country' => "Country", 'create' => "Create user", 'custno' => "Customer No.", 'delete' => "Delete user", 'delete-email' => "Delete {email}", 'delete-text' => "Do you really want to delete this user permanently?" . " This will delete all account data and withdraw the permission to access the email account." . " Please note that this action cannot be undone.", 'discount' => "Discount", 'discount-hint' => "applied discount", 'discount-title' => "Account discount", 'distlists' => "Distribution lists", 'distlists-none' => "There are no distribution lists in this account.", 'domains' => "Domains", 'domains-none' => "There are no domains in this account.", 'ext-email' => "External Email", 'finances' => "Finances", 'greylisting' => "Greylisting", 'greylisting-text' => "Greylisting is a method of defending users against spam. Any incoming mail from an unrecognized sender " . "is temporarily rejected. The originating server should try again after a delay. " . "This time the email will be accepted. Spammers usually do not reattempt mail delivery.", 'list-title' => "User accounts", 'managed-by' => "Managed by", 'new' => "New user account", 'org' => "Organization", 'package' => "Package", 'price' => "Price", 'profile-title' => "Your profile", 'profile-delete' => "Delete account", 'profile-delete-title' => "Delete this account?", 'profile-delete-text1' => "This will delete the account as well as all domains, users and aliases associated with this account.", 'profile-delete-warning' => "This operation is irreversible", 'profile-delete-text2' => "As you will not be able to recover anything after this point, please make sure that you have migrated all data before proceeding.", 'profile-delete-support' => "As we always strive to improve, we would like to ask for 2 minutes of your time. " . "The best tool for improvement is feedback from users, and we would like to ask " . "for a few words about your reasons for leaving our service. Please send your feedback to {email}.", 'profile-delete-contact' => "Also feel free to contact {app} Support with any questions or concerns that you may have in this context.", 'reset-2fa' => "Reset 2-Factor Auth", 'reset-2fa-title' => "2-Factor Authentication Reset", 'title' => "User account", 'search-pl' => "User ID, email or domain", 'skureq' => "{sku} requires {list}.", 'subscription' => "Subscription", 'subscriptions' => "Subscriptions", 'subscriptions-none' => "This user has no subscriptions.", 'users' => "Users", 'users-none' => "There are no users in this account.", ], 'wallet' => [ 'add-credit' => "Add credit", 'auto-payment-cancel' => "Cancel auto-payment", 'auto-payment-change' => "Change auto-payment", 'auto-payment-failed' => "The setup of automatic payments failed. Restart the process to enable automatic top-ups.", 'auto-payment-hint' => "Here is how it works: Every time your account runs low, we will charge your preferred payment method for an amount you choose." . " You can cancel or change the auto-payment option at any time.", 'auto-payment-setup' => "Set up auto-payment", 'auto-payment-disabled' => "The configured auto-payment has been disabled. Top up your wallet or raise the auto-payment amount.", - 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} CHF every time your account balance gets under {balance} CHF.", + 'auto-payment-info' => "Auto-payment is set to fill up your account by {amount} every time your account balance gets under {balance}.", 'auto-payment-inprogress' => "The setup of the automatic payment is still in progress.", 'auto-payment-next' => "Next, you will be redirected to the checkout page, where you can provide your credit card details.", 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 'history' => "History", 'noperm' => "Only account owners can access a wallet.", 'payment-amount-hint' => "Choose the amount by which you want to top up your wallet.", 'payment-method' => "Method of payment: {method}", 'payment-warning' => "You will be charged for {price}.", 'pending-payments' => "Pending Payments", 'pending-payments-warning' => "You have payments that are still in progress. See the \"Pending Payments\" tab below.", 'pending-payments-none' => "There are no pending payments for this account.", 'receipts' => "Receipts", 'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.", 'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.", 'title' => "Account balance", 'top-up' => "Top up your wallet", 'transactions' => "Transactions", 'transactions-none' => "There are no transactions for this account.", 'when-below' => "when account balance is below", ], ]; diff --git a/src/resources/vue/Admin/User.vue b/src/resources/vue/Admin/User.vue index ded084e5..e5742356 100644 --- a/src/resources/vue/Admin/User.vue +++ b/src/resources/vue/Admin/User.vue @@ -1,725 +1,720 @@ diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue index 320e607e..d1975a07 100644 --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -1,66 +1,68 @@ diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index 025f461f..ae52ff39 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,461 +1,461 @@ diff --git a/src/resources/vue/Widgets/PaymentLog.vue b/src/resources/vue/Widgets/PaymentLog.vue index 76775be7..5d887091 100644 --- a/src/resources/vue/Widgets/PaymentLog.vue +++ b/src/resources/vue/Widgets/PaymentLog.vue @@ -1,76 +1,76 @@ diff --git a/src/resources/vue/Widgets/TransactionLog.vue b/src/resources/vue/Widgets/TransactionLog.vue index 9afa8f6c..e26a47a4 100644 --- a/src/resources/vue/Widgets/TransactionLog.vue +++ b/src/resources/vue/Widgets/TransactionLog.vue @@ -1,122 +1,122 @@ diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php index 1514fba8..99241290 100644 --- a/src/tests/Browser/PaymentMollieTest.php +++ b/src/tests/Browser/PaymentMollieTest.php @@ -1,313 +1,313 @@ 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) use ($user) { $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') ->waitFor('#payment-method-selection #creditcard') ->waitFor('#payment-method-selection #paypal') ->waitFor('#payment-method-selection #banktransfer') ->click('#creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->assertFocused('#amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #amount', 'aaa') ->click('@button-action') ->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') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentMollie()) - ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@title', $user->tenant->title . ' Payment') ->assertSeeIn('@amount', 'CHF 12.34'); $this->assertSame(1, $user->wallets()->first()->payments()->count()); // 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-title', 'Account balance 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) use ($user) { $browser->visit(new Home()) ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) ->on(new Dashboard()) ->click('@links .link-wallet') ->on(new WalletPage()) ->assertMissing('@body #mandate-form .alert') ->click('@main #mandate-form button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection #creditcard') ->assertMissing('#payment-method-selection #paypal') ->assertMissing('#payment-method-selection #banktransfer') ->click('#creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up 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') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentMollie()) - ->assertSeeIn('@title', \config('app.name') . ' Auto-Payment Setup') + ->assertSeeIn('@title', $user->tenant->title . ' Auto-Payment Setup') ->assertMissing('@amount') ->submitValidCreditCard() ->waitForLocation('/wallet') ->visit('/wallet?paymentProvider=mollie') ->waitFor('#mandate-info') ->assertPresent('#mandate-info p:first-child') ->assertSeeIn( '#mandate-info p:first-child', 'Auto-payment is set to fill up your account by 100 CHF ' . 'every time your account balance gets under 0 CHF.' ) ->assertSeeIn( '#mandate-info p:nth-child(2)', 'Mastercard (**** **** **** 6787)' ) ->assertMissing('@body .alert'); $this->assertSame(1, $user->wallets()->first()->payments()->count()); }); // Test updating (disabled) auto-payment $this->browse(function (Browser $browser) use ($user) { $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $browser->refresh() ->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn( '#mandate-info .disabled-mandate', 'The configured auto-payment has been disabled' ) ->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment') ->click('#mandate-info button.btn-primary') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Update auto-payment') ->assertSeeIn( '@body form .disabled-mandate', 'The auto-payment is disabled.' ) ->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.') // make sure the "disabled" text isn't there ->assertMissing('#mandate-info .disabled-mandate') ->click('#mandate-info button.btn-primary') ->assertMissing('form .disabled-mandate') ->click('button.modal-cancel'); }); // Test deleting auto-payment $this->browse(function (Browser $browser) { $browser->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment') ->assertVisible('#mandate-info * button.btn-danger') ->click('#mandate-info * button.btn-danger') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') ->assertVisible('#mandate-form') ->assertMissing('#mandate-info'); }); // Test pending and failed mandate $this->browse(function (Browser $browser) { $browser->on(new WalletPage()) ->assertMissing('@body #mandate-form .alert') ->click('@main #mandate-form button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection #creditcard') ->click('#creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Submit valid data ->type('@body #mandate_amount', '100') ->type('@body #mandate_balance', '0') ->click('@button-action'); }) ->on(new PaymentMollie()) ->submitValidCreditCard('open') ->waitForLocation('/wallet') ->visit('/wallet?paymentProvider=mollie') ->on(new WalletPage()) ->assertSeeIn( '#mandate-info .alert-warning', 'The setup of the automatic payment is still in progress.' ) // Delete the mandate ->click('#mandate-info * button.btn-danger') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') ->assertMissing('@body #mandate-form .alert') // Create a new mandate ->click('@main #mandate-form button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection #creditcard') ->click('#creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Submit valid data ->type('@body #mandate_amount', '100') ->type('@body #mandate_balance', '0') ->click('@button-action'); }) ->on(new PaymentMollie()) ->submitValidCreditCard('failed') ->waitForLocation('/wallet') ->visit('/wallet?paymentProvider=mollie') ->on(new WalletPage()) ->waitFor('#mandate-form .alert-danger') ->assertSeeIn( '#mandate-form .alert-danger', 'The setup of automatic payments failed. Restart the process to enable' ) ->click('@main button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->waitFor('#mandate-form') ->assertMissing('#mandate-info'); }); }); } } diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php index 5ce1073e..9ddc0c1b 100644 --- a/src/tests/Browser/PaymentStripeTest.php +++ b/src/tests/Browser/PaymentStripeTest.php @@ -1,234 +1,234 @@ 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') ->waitFor('#payment-method-selection #creditcard') ->waitFor('#payment-method-selection #paypal') ->assertMissing('#payment-method-selection #banktransfer') ->click('#creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->assertFocused('#amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #amount', 'aaa') ->click('@button-action') ->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') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentStripe()) - ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@title', $user->tenant->title . ' 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', 30) // need more time than default 5 sec. ->on(new WalletPage()) ->assertSeeIn('@main .card-title', 'Account balance 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()) ->assertMissing('@body #mandate-form .alert') ->click('@main #mandate-form button') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up auto-payment') ->waitFor('#payment-method-selection #creditcard') ->assertMissing('#payment-method-selection #paypal') ->assertMissing('#payment-method-selection #banktransfer') ->click('#creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Set up 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') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentStripe()) ->assertMissing('@title') ->assertMissing('@amount') ->assertValue('@email-input', $user->email) ->submitValidCreditCard() ->waitForLocation('/wallet', 30) // need more time than default 5 sec. ->visit('/wallet?paymentProvider=stripe') ->waitFor('#mandate-info') ->assertPresent('#mandate-info p:first-child') ->assertSeeIn( '#mandate-info p:first-child', 'Auto-payment is set to fill up your account by 100 CHF ' . 'every time your account balance gets under 0 CHF.' ) ->assertSeeIn( '#mandate-info p:nth-child(2)', 'Visa (**** **** **** 4242)' ) ->assertMissing('@body .alert'); }); // Test updating (disabled) auto-payment $this->browse(function (Browser $browser) use ($user) { $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $browser->refresh() ->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn( '#mandate-info .disabled-mandate', 'The configured auto-payment has been disabled' ) ->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment') ->click('#mandate-info button.btn-primary') ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Update auto-payment') ->assertSeeIn( '@body form .disabled-mandate', 'The auto-payment is disabled.' ) ->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.') // make sure the "disabled" text isn't there ->assertMissing('#mandate-info .disabled-mandate') ->click('#mandate-info button.btn-primary') ->assertMissing('form .disabled-mandate') ->click('button.modal-cancel'); }); // Test deleting auto-payment $this->browse(function (Browser $browser) { $browser->on(new WalletPage()) ->waitFor('#mandate-info') ->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment') ->assertVisible('#mandate-info * button.btn-danger') ->click('#mandate-info * button.btn-danger') ->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.') ->assertVisible('#mandate-form') ->assertMissing('#mandate-info'); }); } } diff --git a/src/tests/Browser/Reseller/PaymentMollieTest.php b/src/tests/Browser/Reseller/PaymentMollieTest.php index b948502f..5859fe94 100644 --- a/src/tests/Browser/Reseller/PaymentMollieTest.php +++ b/src/tests/Browser/Reseller/PaymentMollieTest.php @@ -1,116 +1,116 @@ getTestUser('reseller@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); $wallet->balance = 0; $wallet->save(); parent::tearDown(); } /** * Test the payment process * * @group mollie */ public function testPayment(): void { $this->browse(function (Browser $browser) { $user = $this->getTestUser('reseller@' . \config('app.domain')); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); $wallet->balance = 0; $wallet->save(); $browser->visit(new Home()) ->submitLogon($user->email, \App\Utils::generatePassphrase(), 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') ->waitFor('#payment-method-selection #creditcard') ->waitFor('#payment-method-selection #paypal') ->waitFor('#payment-method-selection #banktransfer') ->click('#creditcard'); }) ->with(new Dialog('@payment-dialog'), function (Browser $browser) { $browser->assertSeeIn('@title', 'Top up your wallet') ->assertFocused('#amount') ->assertSeeIn('@button-cancel', 'Cancel') ->assertSeeIn('@button-action', 'Continue') // Test error handling ->type('@body #amount', 'aaa') ->click('@button-action') ->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') // Note we use double click to assert it does not create redundant requests ->click('@button-action') ->click('@button-action'); }) ->on(new PaymentMollie()) - ->assertSeeIn('@title', \config('app.name') . ' Payment') + ->assertSeeIn('@title', $user->tenant->title . ' Payment') ->assertSeeIn('@amount', 'CHF 12.34'); $this->assertSame(1, $wallet->payments()->count()); // 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-title', 'Account balance 12,34 CHF'); }); } } diff --git a/src/tests/Feature/Controller/Admin/StatsTest.php b/src/tests/Feature/Controller/Admin/StatsTest.php index 2bfb240e..14416b51 100644 --- a/src/tests/Feature/Controller/Admin/StatsTest.php +++ b/src/tests/Feature/Controller/Admin/StatsTest.php @@ -1,88 +1,193 @@ ) */ public function testChart(): void { $user = $this->getTestUser('john@kolab.org'); $admin = $this->getTestUser('jeroen@jeroen.jeroen'); // Non-admin user $response = $this->actingAs($user)->get("api/v4/stats/chart/discounts"); $response->assertStatus(403); // Unknown chart name $response = $this->actingAs($admin)->get("api/v4/stats/chart/unknown"); $response->assertStatus(404); // 'discounts' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Discounts', $json['title']); $this->assertSame('donut', $json['type']); $this->assertSame([], $json['data']['labels']); $this->assertSame([['values' => []]], $json['data']['datasets']); // 'income' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/income"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Income in CHF - last 8 weeks', $json['title']); $this->assertSame('bar', $json['type']); $this->assertCount(8, $json['data']['labels']); $this->assertSame(date('Y-W'), $json['data']['labels'][7]); $this->assertSame([['values' => [0,0,0,0,0,0,0,0]]], $json['data']['datasets']); // 'users' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/users"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('Users - last 8 weeks', $json['title']); $this->assertCount(8, $json['data']['labels']); $this->assertSame(date('Y-W'), $json['data']['labels'][7]); $this->assertCount(2, $json['data']['datasets']); $this->assertSame('Created', $json['data']['datasets'][0]['name']); $this->assertSame('Deleted', $json['data']['datasets'][1]['name']); // 'users-all' chart $response = $this->actingAs($admin)->get("api/v4/stats/chart/users-all"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('All Users - last year', $json['title']); $this->assertCount(54, $json['data']['labels']); $this->assertCount(1, $json['data']['datasets']); } + + /** + * Test income chart currency handling + */ + public function testChartIncomeCurrency(): void + { + $admin = $this->getTestUser('jeroen@jeroen.jeroen'); + $john = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('test-stats@' . \config('app.domain')); + $wallet = $user->wallets()->first(); + $wallet->currency = 'EUR'; + $wallet->save(); + $johns_wallet = $john->wallets()->first(); + + // Create some test payments + Payment::create([ + 'id' => 'test1', + 'description' => '', + 'status' => PaymentProvider::STATUS_PAID, + 'amount' => 1000, // EUR + 'type' => PaymentProvider::TYPE_ONEOFF, + 'wallet_id' => $wallet->id, + 'provider' => 'mollie', + 'currency' => 'EUR', + 'currency_amount' => 1000, + ]); + Payment::create([ + 'id' => 'test2', + 'description' => '', + 'status' => PaymentProvider::STATUS_PAID, + 'amount' => 2000, // EUR + 'type' => PaymentProvider::TYPE_RECURRING, + 'wallet_id' => $wallet->id, + 'provider' => 'mollie', + 'currency' => 'EUR', + 'currency_amount' => 2000, + ]); + Payment::create([ + 'id' => 'test3', + 'description' => '', + 'status' => PaymentProvider::STATUS_PAID, + 'amount' => 3000, // CHF + 'type' => PaymentProvider::TYPE_ONEOFF, + 'wallet_id' => $johns_wallet->id, + 'provider' => 'mollie', + 'currency' => 'EUR', + 'currency_amount' => 2800, + ]); + Payment::create([ + 'id' => 'test4', + 'description' => '', + 'status' => PaymentProvider::STATUS_PAID, + 'amount' => 4000, // CHF + 'type' => PaymentProvider::TYPE_RECURRING, + 'wallet_id' => $johns_wallet->id, + 'provider' => 'mollie', + 'currency' => 'CHF', + 'currency_amount' => 4000, + ]); + Payment::create([ + 'id' => 'test5', + 'description' => '', + 'status' => PaymentProvider::STATUS_OPEN, + 'amount' => 5000, // CHF + 'type' => PaymentProvider::TYPE_ONEOFF, + 'wallet_id' => $johns_wallet->id, + 'provider' => 'mollie', + 'currency' => 'CHF', + 'currency_amount' => 5000, + ]); + Payment::create([ + 'id' => 'test6', + 'description' => '', + 'status' => PaymentProvider::STATUS_FAILED, + 'amount' => 6000, // CHF + 'type' => PaymentProvider::TYPE_ONEOFF, + 'wallet_id' => $johns_wallet->id, + 'provider' => 'mollie', + 'currency' => 'CHF', + 'currency_amount' => 6000, + ]); + + // 'income' chart + $response = $this->actingAs($admin)->get("api/v4/stats/chart/income"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('Income in CHF - last 8 weeks', $json['title']); + $this->assertSame('bar', $json['type']); + $this->assertCount(8, $json['data']['labels']); + $this->assertSame(date('Y-W'), $json['data']['labels'][7]); + + // 7000 CHF + 3000 EUR = + $expected = 7000 + intval(round(3000 * \App\Utils::exchangeRate('EUR', 'CHF'))); + + $this->assertCount(1, $json['data']['datasets']); + $this->assertSame($expected / 100, $json['data']['datasets'][0]['values'][7]); + } } diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php similarity index 83% copy from src/tests/Feature/Controller/PaymentsMollieTest.php copy to src/tests/Feature/Controller/PaymentsMollieEuroTest.php index 2ed0af63..3a3ac06d 100644 --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php @@ -1,1062 +1,928 @@ 'mollie']); - - $john = $this->getTestUser('john@kolab.org'); - $wallet = $john->wallets()->first(); - Payment::where('wallet_id', $wallet->id)->delete(); - Wallet::where('id', $wallet->id)->update(['balance' => 0]); - WalletSetting::where('wallet_id', $wallet->id)->delete(); - $types = [ - Transaction::WALLET_CREDIT, - Transaction::WALLET_REFUND, - Transaction::WALLET_CHARGEBACK, - ]; - Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { - $john = $this->getTestUser('john@kolab.org'); - $wallet = $john->wallets()->first(); - Payment::where('wallet_id', $wallet->id)->delete(); - Wallet::where('id', $wallet->id)->update(['balance' => 0]); - WalletSetting::where('wallet_id', $wallet->id)->delete(); - $types = [ - Transaction::WALLET_CREDIT, - Transaction::WALLET_REFUND, - Transaction::WALLET_CHARGEBACK, - ]; - Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); + $this->deleteTestUser('euro@' . \config('app.domain')); 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'); + $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); + $wallet->currency = 'EUR'; + $wallet->save(); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (amount smaller than the minimum value) $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); - $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + $this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']); // Test creating a mandate (negative balance, amount too small) Wallet::where('id', $wallet->id)->update(['balance' => -2000]); $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']); // Test creating a mandate (valid input) $post = ['amount' => 20.10, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); // Assert the proper payment amount has been used $payment = Payment::where('id', $json['id'])->first(); $this->assertSame(2010, $payment->amount); $this->assertSame($wallet->id, $payment->wallet_id); - $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description); + $this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ 'cardNumber' => '4242', 'cardLabel' => 'Visa', ], 'customerId' => 'cst_GMfxGPt7Gj', 'createdAt' => '2020-04-28T11:09:47+00:00', ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); Bus::fake(); $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); // Test updating mandate details (invalid input) $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + $this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']); // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockMollie(); // Delete mandate $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been removed.', $json['message']); // Confirm with Mollie the mandate does not exist $customer_id = $wallet->getSetting('mollie_id'); $this->expectException(\Mollie\Api\Exceptions\ApiException::class); $this->expectExceptionMessageMatches('/410: Gone/'); $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); // Test Mollie's "410 Gone" response handling when fetching the mandate info // It is expected to remove the mandate reference $mollie_response = [ 'status' => 410, 'title' => "Gone", 'detail' => "You are trying to access an object, which has previously been deleted", '_links' => [ 'documentation' => [ 'href' => "https://docs.mollie.com/errors", 'type' => "text/html" ] ] ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(410, [], json_encode($mollie_response))); $wallet->fresh()->setSetting('mollie_mandate_id', '123'); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse(array_key_exists('id', $json)); $this->assertFalse(array_key_exists('method', $json)); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); } /** * Test creating a payment and receiving a status via webhook * * @group mollie */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); - // Invalid amount - $user = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('euro@' . \config('app.domain')); + $wallet = $user->wallets()->first(); + $wallet->currency = 'EUR'; + $wallet->save(); + // Invalid amount $post = ['amount' => -1]; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); - $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + $this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']); // Invalid currency $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(500); // Successful payment - $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; + $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); - $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(1234, $payment->currency_amount); - $this->assertSame('CHF', $payment->currency); - $this->assertSame(\config('app.name') . ' Payment', $payment->description); + $this->assertSame('EUR', $payment->currency); + $this->assertSame($user->tenant->title . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook // Note: Webhook end-point does not require authentication $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Verify "paid -> open -> paid" scenario, assert that balance didn't change $mollie_response['status'] = 'open'; unset($mollie_response['paidAt']); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $mollie_response['status'] = 'paid'; $mollie_response['paidAt'] = date('c'); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame('failed', $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } - /** - * Test creating a payment and receiving a status via webhook using a foreign currency - * - * @group mollie - */ - public function testStoreAndWebhookForeignCurrency(): void - { - Bus::fake(); - - $user = $this->getTestUser('john@kolab.org'); - $wallet = $user->wallets()->first(); - - // Successful payment in EUR - $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'banktransfer']; - $response = $this->actingAs($user)->post("api/v4/payments", $post); - $response->assertStatus(200); - - $payment = $wallet->payments() - ->where('currency', 'EUR')->get()->last(); - - $this->assertSame(1234, $payment->amount); - $this->assertSame(1117, $payment->currency_amount); - $this->assertSame('EUR', $payment->currency); - $this->assertEquals(0, $wallet->balance); - - $mollie_response = [ - "resource" => "payment", - "id" => $payment->id, - "status" => "paid", - // Status is not enough, paidAt is used to distinguish the state - "paidAt" => date('c'), - "mode" => "test", - ]; - - $responseStack = $this->mockMollie(); - $responseStack->append(new Response(200, [], json_encode($mollie_response))); - - $post = ['id' => $payment->id]; - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); - $this->assertEquals(1234, $wallet->fresh()->balance); - } - /** * Test automatic payment charges * * @group mollie */ public function testTopUp(): void { Bus::fake(); - $user = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); + $wallet->currency = 'EUR'; + $wallet->save(); // Create a valid mandate first (balance=0, so there's no extra payment yet) $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]); $wallet->setSetting('mandate_balance', 10); // Expect a recurring payment as we have a valid mandate at this point // and the balance is below the threshold $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount. // There should be two records, one for the mandate payment and another for // the top-up payment $payments = $wallet->payments()->orderBy('amount')->get(); $this->assertCount(2, $payments); $this->assertSame(0, $payments[0]->amount); $this->assertSame(0, $payments[0]->currency_amount); $this->assertSame(2010, $payments[1]->amount); $this->assertSame(2010, $payments[1]->currency_amount); $payment = $payments[1]; // In mollie we don't have to wait for a webhook, the response to // PaymentIntent already sets the status to 'paid', so we can test // immediately the balance update // Assert that email notification job has been dispatched $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)", $transaction->description ); Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); // Test webhook for recurring payments $wallet->transactions()->delete(); $responseStack = $this->mockMollie(); Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mollie_mandate_id', 'xxx'); $wallet->setSetting('mandate_disabled', null); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook * * @group mollie */ public function testRefundAndChargeback(): void { Bus::fake(); - $user = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('euro@' . \config('app.domain')); $wallet = $user->wallets()->first(); + $wallet->currency = 'EUR'; + $wallet->save(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, 'currency_amount' => 123, - 'currency' => 'CHF', + 'currency' => 'EUR', 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ - "currency" => "CHF", + "currency" => "EUR", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-101, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get(); $this->assertCount(1, $transactions); $this->assertSame(-101, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type); $this->assertSame("refund desc", $transactions[0]->description); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-101, $payments[0]->amount); $this->assertSame(-101, $payments[0]->currency_amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame("refund desc", $payments[0]->description); // Test handling a chargeback by the webhook $mollie_response1["_links"] = [ "chargebacks" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks", "type" => "application/hal+json" ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "chargebacks" => [ [ "resource" => "chargeback", "id" => "chb_123456", "paymentId" => $payment->id, "amount" => [ - "currency" => "CHF", + "currency" => "EUR", "value" => "0.15", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-116, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get(); $this->assertCount(1, $transactions); $this->assertSame(-15, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type); $this->assertSame('', $transactions[0]->description); $payments = $wallet->payments()->where('id', 'chb_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-15, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame('', $payments[0]->description); Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class); $this->unmockMollie(); } - /** - * Test refund/chargeback handling by the webhook in a foreign currency - * - * @group mollie - */ - public function testRefundAndChargebackForeignCurrency(): void - { - Bus::fake(); - - $user = $this->getTestUser('john@kolab.org'); - $wallet = $user->wallets()->first(); - $wallet->transactions()->delete(); - - $mollie = PaymentProvider::factory('mollie'); - - // Create a paid payment - $payment = Payment::create([ - 'id' => 'tr_123456', - 'status' => PaymentProvider::STATUS_PAID, - 'amount' => 1234, - 'currency_amount' => 1117, - 'currency' => 'EUR', - 'type' => PaymentProvider::TYPE_ONEOFF, - 'wallet_id' => $wallet->id, - 'provider' => 'mollie', - 'description' => 'test', - ]); - - // Test handling a refund by the webhook - - $mollie_response1 = [ - "resource" => "payment", - "id" => $payment->id, - "status" => "paid", - // Status is not enough, paidAt is used to distinguish the state - "paidAt" => date('c'), - "mode" => "test", - "_links" => [ - "refunds" => [ - "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", - "type" => "application/hal+json" - ] - ] - ]; - - $mollie_response2 = [ - "count" => 1, - "_links" => [], - "_embedded" => [ - "refunds" => [ - [ - "resource" => "refund", - "id" => "re_123456", - "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, - "paymentId" => $payment->id, - "description" => "refund desc", - "amount" => [ - "currency" => "EUR", - "value" => "1.01", - ], - ] - ] - ] - ]; - - // We'll trigger the webhook with payment id and use mocking for - // requests to the Mollie payments API. - $responseStack = $this->mockMollie(); - $responseStack->append(new Response(200, [], json_encode($mollie_response1))); - $responseStack->append(new Response(200, [], json_encode($mollie_response2))); - - $post = ['id' => $payment->id]; - $response = $this->post("api/webhooks/payment/mollie", $post); - $response->assertStatus(200); - - $wallet->refresh(); - - $this->assertTrue($wallet->balance <= -108); - $this->assertTrue($wallet->balance >= -114); - - $payments = $wallet->payments()->where('id', 're_123456')->get(); - - $this->assertCount(1, $payments); - $this->assertTrue($payments[0]->amount <= -108); - $this->assertTrue($payments[0]->amount >= -114); - $this->assertSame(-101, $payments[0]->currency_amount); - $this->assertSame('EUR', $payments[0]->currency); - - $this->unmockMollie(); - } - /** * Create Mollie's auto-payment mandate using our API and Chrome browser */ protected function createMandate(Wallet $wallet, array $params) { // Use the API to create a first payment with a mandate $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params); $response->assertStatus(200); $json = $response->json(); // There's no easy way to confirm a created mandate. // The only way seems to be to fire up Chrome on checkout page // and do actions with use of Dusk browser. $this->startBrowser() ->visit($json['redirectUrl']) ->click('input[value="paid"]') ->click('button.form__button'); $this->stopBrowser(); } - /** * Test listing a pending payment * * @group mollie */ public function testListingPayments(): void { Bus::fake(); - $user = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('euro@' . \config('app.domain')); + $wallet = $user->wallets()->first(); + $wallet->currency = 'EUR'; + $wallet->save(); //Empty response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); - $wallet = $user->wallets()->first(); - // Successful payment - $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; + $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); //A response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(1, $json['list']); $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']); + $this->assertSame('EUR', $json['list'][0]['currency']); + $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']); + $this->assertSame(1234, $json['list'][0]['amount']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(true, $json['hasPending']); // Set the payment to paid $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $payment->status = PaymentProvider::STATUS_PAID; $payment->save(); // They payment should be gone from the pending list now $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); } /** * Test listing payment methods * * @group mollie */ public function testListingPaymentMethods(): void { Bus::fake(); - $user = $this->getTestUser('john@kolab.org'); + $user = $this->getTestUser('euro@' . \config('app.domain')); + $wallet = $user->wallets()->first(); + $wallet->currency = 'EUR'; + $wallet->save(); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('paypal', $json[1]['id']); $this->assertSame('banktransfer', $json[2]['id']); + $this->assertSame('EUR', $json[0]['currency']); + $this->assertSame('EUR', $json[1]['currency']); + $this->assertSame('EUR', $json[2]['currency']); + $this->assertSame(1, $json[2]['exchangeRate']); + $this->assertSame(1, $json[2]['exchangeRate']); + $this->assertSame(1, $json[2]['exchangeRate']); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('creditcard', $json[0]['id']); + $this->assertSame('EUR', $json[0]['currency']); } } diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php index 2ed0af63..5229345e 100644 --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -1,1062 +1,1069 @@ 'mollie']); $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); $types = [ Transaction::WALLET_CREDIT, Transaction::WALLET_REFUND, Transaction::WALLET_CHARGEBACK, ]; Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); parent::tearDown(); } /** * Test creating/updating/deleting an outo-payment mandate * * @group mollie */ public function testMandates(): void { // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); $response = $this->post("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->put("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->delete("api/v4/payments/mandate"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (amount smaller than the minimum value) $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); - $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test creating a mandate (negative balance, amount too small) Wallet::where('id', $wallet->id)->update(['balance' => -2000]); $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']); // Test creating a mandate (valid input) $post = ['amount' => 20.10, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); // Assert the proper payment amount has been used $payment = Payment::where('id', $json['id'])->first(); $this->assertSame(2010, $payment->amount); $this->assertSame($wallet->id, $payment->wallet_id); - $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description); + $this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ 'cardNumber' => '4242', 'cardLabel' => 'Visa', ], 'customerId' => 'cst_GMfxGPt7Gj', 'createdAt' => '2020-04-28T11:09:47+00:00', ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $wallet = $user->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); Bus::fake(); $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); // Test updating mandate details (invalid input) $post = []; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); $post = ['amount' => -100, 'balance' => 0]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockMollie(); // Delete mandate $response = $this->actingAs($user)->delete("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been removed.', $json['message']); // Confirm with Mollie the mandate does not exist $customer_id = $wallet->getSetting('mollie_id'); $this->expectException(\Mollie\Api\Exceptions\ApiException::class); $this->expectExceptionMessageMatches('/410: Gone/'); $mandate = mollie()->mandates()->getForId($customer_id, $mandate_id); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); // Test Mollie's "410 Gone" response handling when fetching the mandate info // It is expected to remove the mandate reference $mollie_response = [ 'status' => 410, 'title' => "Gone", 'detail' => "You are trying to access an object, which has previously been deleted", '_links' => [ 'documentation' => [ 'href' => "https://docs.mollie.com/errors", 'type' => "text/html" ] ] ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(410, [], json_encode($mollie_response))); $wallet->fresh()->setSetting('mollie_mandate_id', '123'); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertFalse(array_key_exists('id', $json)); $this->assertFalse(array_key_exists('method', $json)); $this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id')); } /** * Test creating a payment and receiving a status via webhook * * @group mollie */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); - // Invalid amount $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + // Invalid amount $post = ['amount' => -1]; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); - $min = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Invalid currency $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(500); // Successful payment $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); - $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(1234, $payment->currency_amount); $this->assertSame('CHF', $payment->currency); - $this->assertSame(\config('app.name') . ' Payment', $payment->description); + $this->assertSame($user->tenant->title . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook // Note: Webhook end-point does not require authentication $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Verify "paid -> open -> paid" scenario, assert that balance didn't change $mollie_response['status'] = 'open'; unset($mollie_response['paidAt']); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $mollie_response['status'] = 'paid'; $mollie_response['paidAt'] = date('c'); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame('failed', $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Test creating a payment and receiving a status via webhook using a foreign currency * * @group mollie */ public function testStoreAndWebhookForeignCurrency(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Successful payment in EUR $post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'banktransfer']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $payment = $wallet->payments() ->where('currency', 'EUR')->get()->last(); $this->assertSame(1234, $payment->amount); $this->assertSame(1117, $payment->currency_amount); $this->assertSame('EUR', $payment->currency); $this->assertEquals(0, $wallet->balance); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); } /** * Test automatic payment charges * * @group mollie */ public function testTopUp(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Create a valid mandate first (balance=0, so there's no extra payment yet) $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]); $wallet->setSetting('mandate_balance', 10); // Expect a recurring payment as we have a valid mandate at this point // and the balance is below the threshold $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount. // There should be two records, one for the mandate payment and another for // the top-up payment $payments = $wallet->payments()->orderBy('amount')->get(); $this->assertCount(2, $payments); $this->assertSame(0, $payments[0]->amount); $this->assertSame(0, $payments[0]->currency_amount); $this->assertSame(2010, $payments[1]->amount); $this->assertSame(2010, $payments[1]->currency_amount); $payment = $payments[1]; // In mollie we don't have to wait for a webhook, the response to // PaymentIntent already sets the status to 'paid', so we can test // immediately the balance update // Assert that email notification job has been dispatched $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 6787)", $transaction->description ); Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(2, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); // Test webhook for recurring payments $wallet->transactions()->delete(); $responseStack = $this->mockMollie(); Bus::fake(); $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", ]; // We'll trigger the webhook with payment id and use mocking for // a request to the Mollie payments API. We cannot force Mollie // to make the payment status change. $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Mollie", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mollie_mandate_id', 'xxx'); $wallet->setSetting('mandate_disabled', null); $mollie_response = [ "resource" => "payment", "id" => $payment->id, "status" => "failed", "mode" => "test", ]; $responseStack->append(new Response(200, [], json_encode($mollie_response))); $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook * * @group mollie */ public function testRefundAndChargeback(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, 'currency_amount' => 123, 'currency' => 'CHF', 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ "currency" => "CHF", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-101, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get(); $this->assertCount(1, $transactions); $this->assertSame(-101, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type); $this->assertSame("refund desc", $transactions[0]->description); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-101, $payments[0]->amount); $this->assertSame(-101, $payments[0]->currency_amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame("refund desc", $payments[0]->description); // Test handling a chargeback by the webhook $mollie_response1["_links"] = [ "chargebacks" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks", "type" => "application/hal+json" ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "chargebacks" => [ [ "resource" => "chargeback", "id" => "chb_123456", "paymentId" => $payment->id, "amount" => [ "currency" => "CHF", "value" => "0.15", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertEquals(-116, $wallet->balance); $transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get(); $this->assertCount(1, $transactions); $this->assertSame(-15, $transactions[0]->amount); $this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type); $this->assertSame('', $transactions[0]->description); $payments = $wallet->payments()->where('id', 'chb_123456')->get(); $this->assertCount(1, $payments); $this->assertSame(-15, $payments[0]->amount); $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status); $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type); $this->assertSame("mollie", $payments[0]->provider); $this->assertSame('', $payments[0]->description); Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class); $this->unmockMollie(); } /** * Test refund/chargeback handling by the webhook in a foreign currency * * @group mollie */ public function testRefundAndChargebackForeignCurrency(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->transactions()->delete(); $mollie = PaymentProvider::factory('mollie'); // Create a paid payment $payment = Payment::create([ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 1234, 'currency_amount' => 1117, 'currency' => 'EUR', 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', 'description' => 'test', ]); // Test handling a refund by the webhook $mollie_response1 = [ "resource" => "payment", "id" => $payment->id, "status" => "paid", // Status is not enough, paidAt is used to distinguish the state "paidAt" => date('c'), "mode" => "test", "_links" => [ "refunds" => [ "href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds", "type" => "application/hal+json" ] ] ]; $mollie_response2 = [ "count" => 1, "_links" => [], "_embedded" => [ "refunds" => [ [ "resource" => "refund", "id" => "re_123456", "status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED, "paymentId" => $payment->id, "description" => "refund desc", "amount" => [ "currency" => "EUR", "value" => "1.01", ], ] ] ] ]; // We'll trigger the webhook with payment id and use mocking for // requests to the Mollie payments API. $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response1))); $responseStack->append(new Response(200, [], json_encode($mollie_response2))); $post = ['id' => $payment->id]; $response = $this->post("api/webhooks/payment/mollie", $post); $response->assertStatus(200); $wallet->refresh(); $this->assertTrue($wallet->balance <= -108); $this->assertTrue($wallet->balance >= -114); $payments = $wallet->payments()->where('id', 're_123456')->get(); $this->assertCount(1, $payments); $this->assertTrue($payments[0]->amount <= -108); $this->assertTrue($payments[0]->amount >= -114); $this->assertSame(-101, $payments[0]->currency_amount); $this->assertSame('EUR', $payments[0]->currency); $this->unmockMollie(); } /** * Create Mollie's auto-payment mandate using our API and Chrome browser */ protected function createMandate(Wallet $wallet, array $params) { // Use the API to create a first payment with a mandate $response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params); $response->assertStatus(200); $json = $response->json(); // There's no easy way to confirm a created mandate. // The only way seems to be to fire up Chrome on checkout page // and do actions with use of Dusk browser. $this->startBrowser() ->visit($json['redirectUrl']) ->click('input[value="paid"]') ->click('button.form__button'); $this->stopBrowser(); } /** * Test listing a pending payment * * @group mollie */ public function testListingPayments(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); //Empty response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); $wallet = $user->wallets()->first(); // Successful payment $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); //A response $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(1, $json['list']); $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']); + $this->assertSame('CHF', $json['list'][0]['currency']); + $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']); + $this->assertSame(1234, $json['list'][0]['amount']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(true, $json['hasPending']); // Set the payment to paid $payments = Payment::where('wallet_id', $wallet->id)->get(); $this->assertCount(1, $payments); $payment = $payments[0]; $payment->status = PaymentProvider::STATUS_PAID; $payment->save(); // They payment should be gone from the pending list now $response = $this->actingAs($user)->get("api/v4/payments/pending"); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertCount(0, $json['list']); $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); $json = $response->json(); $this->assertSame(false, $json['hasPending']); } /** * Test listing payment methods * * @group mollie */ public function testListingPaymentMethods(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('paypal', $json[1]['id']); $this->assertSame('banktransfer', $json[2]['id']); + $this->assertSame('CHF', $json[0]['currency']); + $this->assertSame('CHF', $json[1]['currency']); + $this->assertSame('EUR', $json[2]['currency']); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('creditcard', $json[0]['id']); + $this->assertSame('CHF', $json[0]['currency']); } } diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php index 6114c994..3598e381 100644 --- a/src/tests/Feature/Controller/PaymentsStripeTest.php +++ b/src/tests/Feature/Controller/PaymentsStripeTest.php @@ -1,743 +1,742 @@ 'stripe']); $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_CREDIT)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $john = $this->getTestUser('john@kolab.org'); $wallet = $john->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); Transaction::where('object_id', $wallet->id) ->where('type', Transaction::WALLET_CREDIT)->delete(); parent::tearDown(); } /** * Test creating/updating/deleting an outo-payment mandate * * @group stripe */ public function testMandates(): void { Bus::fake(); // Unauth access not allowed $response = $this->get("api/v4/payments/mandate"); $response->assertStatus(401); $response = $this->post("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->put("api/v4/payments/mandate", []); $response->assertStatus(401); $response = $this->delete("api/v4/payments/mandate"); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Test creating a mandate (invalid input) $post = []; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(2, $json['errors']); $this->assertSame('The amount field is required.', $json['errors']['amount'][0]); $this->assertSame('The balance field is required.', $json['errors']['balance'][0]); // Test creating a mandate (invalid input) $post = ['amount' => 100, 'balance' => 'a']; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The balance must be a number.', $json['errors']['balance'][0]); // Test creating a mandate (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'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); // Test creating a mandate (negative balance, amount too small) Wallet::where('id', $wallet->id)->update(['balance' => -2000]); $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']); // Test creating a mandate (valid input) $post = ['amount' => 20.10, 'balance' => 0]; $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^cs_test_|', $json['id']); // Assert the proper payment amount has been used // Stripe in 'setup' mode does not allow to set the amount $payment = Payment::where('wallet_id', $wallet->id)->first(); $this->assertSame(0, $payment->amount); - $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description); + $this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertSame(false, $json['isDisabled']); // 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", "customer": null }'; $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'); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($user)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); // Test updating mandate details (invalid input) $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $user->refresh(); $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) $client->addResponse($setupIntent); $client->addResponse($paymentMethod); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); $this->assertSame('AAA', $json['id']); $this->assertFalse($json['isDisabled']); // Test updating a disabled mandate (invalid input) $wallet->setSetting('mandate_disabled', 1); $wallet->balance = -2000; $wallet->save(); $user->refresh(); // required so the controller sees the wallet update from above $post = ['amount' => 15.10, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(422); $json = $response->json(); $this->assertSame('error', $json['status']); $this->assertCount(1, $json['errors']); $this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']); // Test updating a disabled mandate (valid input) $client->addResponse($setupIntent); $client->addResponse($paymentMethod); $post = ['amount' => 30, 'balance' => 1]; $response = $this->actingAs($user)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame('AAA', $json['id']); $this->assertFalse($json['isDisabled']); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); $this->unmockStripe(); // TODO: Delete mandate } /** * Test creating a payment and receiving a status via webhook * * @group stripe */ public function testStoreAndWebhook(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); $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'; + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); - // Invalid currency $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(500); // Successful payment $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; $response = $this->actingAs($user)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^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($user->tenant->title . ' Payment', $payment->description); $this->assertSame('open', $payment->status); $this->assertEquals(0, $wallet->balance); // Test the webhook $post = [ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", 'object' => "event", 'api_version' => "2020-03-02", 'created' => 1590147209, 'data' => [ 'object' => [ 'id' => $payment->id, 'object' => "payment_intent", 'amount' => 1234, 'amount_capturable' => 0, 'amount_received' => 1234, 'capture_method' => "automatic", 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48", 'confirmation_method' => "automatic", 'created' => 1590147204, 'currency' => "chf", 'customer' => "cus_HKDZ53OsKdlM83", 'last_payment_error' => null, 'livemode' => false, 'metadata' => [], 'receipt_email' => "payment-test@kolabnow.com", 'status' => "succeeded" ] ], 'type' => "payment_intent.succeeded" ]; // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(1234, $transaction->amount); $this->assertSame( "Payment transaction {$payment->id} using Stripe", $transaction->description ); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Test that balance didn't change if the same event is posted $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Test for payment failure ('failed' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $post['type'] = "payment_intent.payment_failed"; $post['data']['object']['status'] = 'failed'; $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); // Test for payment failure ('canceled' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $post['type'] = "payment_intent.canceled"; $post['data']['object']['status'] = 'canceled'; $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status); $this->assertEquals(1234, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Test receiving webhook request for setup intent * * @group stripe */ public function testCreateMandateAndWebhook(): void { $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); Wallet::where('id', $wallet->id)->update(['balance' => -1000]); // 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); $payment = $wallet->payments()->first(); $this->assertSame(PaymentProvider::STATUS_OPEN, $payment->status); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); $this->assertSame(0, $payment->amount); $post = [ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", 'object' => "event", 'api_version' => "2020-03-02", 'created' => 1590147209, 'data' => [ 'object' => [ 'id' => $payment->id, 'object' => "setup_intent", 'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48", 'created' => 1590147204, 'customer' => "cus_HKDZ53OsKdlM83", 'last_setup_error' => null, 'metadata' => [], 'status' => "succeeded" ] ], 'type' => "setup_intent.succeeded" ]; Bus::fake(); // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); $payment->refresh(); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status); $this->assertSame($payment->id, $wallet->fresh()->getSetting('stripe_mandate_id')); // Expect a WalletCharge job if the balance is negative Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1); Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) { $job_wallet = TestCase::getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // TODO: test other setup_intent.* events } /** * Test automatic payment charges * * @group stripe */ public function testTopUpAndWebhook(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Stripe API does not allow us to create a mandate easily // That's why we we'll mock API responses // Create a fake mandate $wallet->setSettings([ 'mandate_amount' => 20.10, 'mandate_balance' => 10, 'stripe_mandate_id' => 'AAA', ]); $setupIntent = json_encode([ "id" => "AAA", "object" => "setup_intent", "created" => 123456789, "payment_method" => "pm_YYY", "status" => "succeeded", "usage" => "off_session", "customer" => null ]); $paymentMethod = json_encode([ "id" => "pm_YYY", "object" => "payment_method", "card" => [ "brand" => "visa", "country" => "US", "last4" => "4242" ], "created" => 123456789, "type" => "card" ]); $paymentIntent = json_encode([ "id" => "pi_XX", "object" => "payment_intent", "created" => 123456789, "amount" => 2010, "currency" => "chf", - "description" => "Kolab Recurring Payment" + "description" => $user->tenant->title . " Recurring Payment" ]); $client = $this->mockStripe(); $client->addResponse($setupIntent); $client->addResponse($paymentMethod); $client->addResponse($setupIntent); $client->addResponse($paymentIntent); $client->addResponse($setupIntent); $client->addResponse($paymentMethod); // Expect a recurring payment as we have a valid mandate at this point $result = PaymentsController::topUpWallet($wallet); $this->assertTrue($result); // Check that the payments table contains a new record with proper amount // There should be two records, one for the first payment and another for // the recurring payment $this->assertCount(1, $wallet->payments()->get()); $payment = $wallet->payments()->first(); $this->assertSame(2010, $payment->amount); - $this->assertSame(\config('app.name') . " Recurring Payment", $payment->description); + $this->assertSame($user->tenant->title . " Recurring Payment", $payment->description); $this->assertSame("pi_XX", $payment->id); // Expect no payment if the mandate is disabled $wallet->setSetting('mandate_disabled', 1); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); // Expect no payment if balance is ok $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); // Expect no payment if the top-up amount is not enough $wallet->setSetting('mandate_disabled', null); $wallet->balance = -2050; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) { $job_wallet = $this->getObjectProperty($job, 'wallet'); return $job_wallet->id === $wallet->id; }); // Expect no payment if there's no mandate $wallet->setSetting('mollie_mandate_id', null); $wallet->balance = 0; $wallet->save(); $result = PaymentsController::topUpWallet($wallet); $this->assertFalse($result); $this->assertCount(1, $wallet->payments()->get()); Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1); $this->unmockStripe(); // Test webhook $post = [ 'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa", 'object' => "event", 'api_version' => "2020-03-02", 'created' => 1590147209, 'data' => [ 'object' => [ 'id' => $payment->id, 'object' => "payment_intent", 'amount' => 2010, 'capture_method' => "automatic", 'created' => 1590147204, 'currency' => "chf", 'customer' => "cus_HKDZ53OsKdlM83", 'last_payment_error' => null, 'metadata' => [], 'receipt_email' => "payment-test@kolabnow.com", 'status' => "succeeded" ] ], 'type' => "payment_intent.succeeded" ]; // Test payment succeeded event $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); $transaction = $wallet->transactions() ->where('type', Transaction::WALLET_CREDIT)->get()->last(); $this->assertSame(2010, $transaction->amount); $this->assertSame( "Auto-payment transaction {$payment->id} using Stripe", $transaction->description ); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure ('failed' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $wallet->setSetting('mandate_disabled', null); $post['type'] = "payment_intent.payment_failed"; $post['data']['object']['status'] = 'failed'; $response = $this->webhookRequest($post); $response->assertStatus(200); $wallet->refresh(); $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->balance); $this->assertTrue(!empty($wallet->getSetting('mandate_disabled'))); // Assert that email notification job has been dispatched Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1); Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) { $job_payment = $this->getObjectProperty($job, 'payment'); return $job_payment->id === $payment->id; }); Bus::fake(); // Test for payment failure ('canceled' status) $payment->refresh(); $payment->status = PaymentProvider::STATUS_OPEN; $payment->save(); $post['type'] = "payment_intent.canceled"; $post['data']['object']['status'] = 'canceled'; $response = $this->webhookRequest($post); $response->assertStatus(200); $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status); $this->assertEquals(2010, $wallet->fresh()->balance); // Assert that email notification job wasn't dispatched, // it is expected only for recurring payments Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); } /** * Generate Stripe-Signature header for a webhook payload */ protected function webhookRequest($post) { $secret = \config('services.stripe.webhook_secret'); $ts = time(); $payload = "$ts." . json_encode($post); $sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret)); return $this->withHeaders(['Stripe-Signature' => $sig]) ->json('POST', "api/webhooks/payment/stripe", $post); } /** * Test listing payment methods * * @group stripe */ public function testListingPaymentMethods(): void { Bus::fake(); $user = $this->getTestUser('john@kolab.org'); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); $response->assertStatus(200); $json = $response->json(); $this->assertCount(2, $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('paypal', $json[1]['id']); $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING); $response->assertStatus(200); $json = $response->json(); $this->assertCount(1, $json); $this->assertSame('creditcard', $json[0]['id']); } } diff --git a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php index f10b582f..c3f18070 100644 --- a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php @@ -1,258 +1,258 @@ 'mollie']); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $wallet = $reseller->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); Transaction::where('object_id', $wallet->id)->delete(); } /** * {@inheritDoc} */ public function tearDown(): void { $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $wallet = $reseller->wallets()->first(); Payment::where('wallet_id', $wallet->id)->delete(); Wallet::where('id', $wallet->id)->update(['balance' => 0]); WalletSetting::where('wallet_id', $wallet->id)->delete(); Transaction::where('object_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); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $wallet = $reseller->wallets()->first(); $wallet->balance = -10; $wallet->save(); // Test creating a mandate (valid input) $post = ['amount' => 20.10, 'balance' => 0]; $response = $this->actingAs($reseller)->post("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); // Assert the proper payment amount has been used $payment = Payment::where('id', $json['id'])->first(); $this->assertSame(2010, $payment->amount); $this->assertSame($wallet->id, $payment->wallet_id); - $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description); + $this->assertSame($reseller->tenant->title . " Auto-Payment Setup", $payment->description); $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type); // Test fetching the mandate information $response = $this->actingAs($reseller)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Credit Card', $json['method']); $this->assertSame(true, $json['isPending']); $this->assertSame(false, $json['isValid']); $this->assertSame(false, $json['isDisabled']); $mandate_id = $json['id']; // We would have to invoke a browser to accept the "first payment" to make // the mandate validated/completed. Instead, we'll mock the mandate object. $mollie_response = [ 'resource' => 'mandate', 'id' => $mandate_id, 'status' => 'valid', 'method' => 'creditcard', 'details' => [ 'cardNumber' => '4242', 'cardLabel' => 'Visa', ], 'customerId' => 'cst_GMfxGPt7Gj', 'createdAt' => '2020-04-28T11:09:47+00:00', ]; $responseStack = $this->mockMollie(); $responseStack->append(new Response(200, [], json_encode($mollie_response))); $wallet = $reseller->wallets()->first(); $wallet->setSetting('mandate_disabled', 1); $response = $this->actingAs($reseller)->get("api/v4/payments/mandate"); $response->assertStatus(200); $json = $response->json(); $this->assertEquals(20.10, $json['amount']); $this->assertEquals(0, $json['balance']); $this->assertEquals('Visa (**** **** **** 4242)', $json['method']); $this->assertSame(false, $json['isPending']); $this->assertSame(true, $json['isValid']); $this->assertSame(true, $json['isDisabled']); Bus::fake(); $wallet->setSetting('mandate_disabled', null); $wallet->balance = 1000; $wallet->save(); // Test updating a mandate (valid input) $responseStack->append(new Response(200, [], json_encode($mollie_response))); $post = ['amount' => 30.10, 'balance' => 10]; $response = $this->actingAs($reseller)->put("api/v4/payments/mandate", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame('The auto-payment has been updated.', $json['message']); $this->assertSame($mandate_id, $json['id']); $this->assertFalse($json['isDisabled']); $wallet->refresh(); $this->assertEquals(30.10, $wallet->getSetting('mandate_amount')); $this->assertEquals(10, $wallet->getSetting('mandate_balance')); Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0); $this->unmockMollie(); // Delete mandate $response = $this->actingAs($reseller)->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']); } /** * Test creating a payment * * @group mollie */ public function testStore(): void { Bus::fake(); // Unauth access not allowed $response = $this->post("api/v4/payments", []); $response->assertStatus(401); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); // Successful payment $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard']; $response = $this->actingAs($reseller)->post("api/v4/payments", $post); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']); } /** * Test listing a pending payment * * @group mollie */ public function testListingPayments(): void { Bus::fake(); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); // Empty response $response = $this->actingAs($reseller)->get("api/v4/payments/pending"); $response->assertStatus(200); $json = $response->json(); $this->assertSame('success', $json['status']); $this->assertSame(0, $json['count']); $this->assertSame(1, $json['page']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); $response = $this->actingAs($reseller)->get("api/v4/payments/has-pending"); $response->assertStatus(200); $json = $response->json(); $this->assertSame(false, $json['hasPending']); } /** * Test listing payment methods * * @group mollie */ public function testListingPaymentMethods(): void { Bus::fake(); $reseller = $this->getTestUser('reseller@' . \config('app.domain')); $response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); $response->assertStatus(200); $json = $response->json(); $this->assertCount(3, $json); $this->assertSame('creditcard', $json[0]['id']); $this->assertSame('paypal', $json[1]['id']); $this->assertSame('banktransfer', $json[2]['id']); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 0fc1766d..b03f84e3 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,355 +1,356 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } /** * Test for getWalletNotice() method */ public function testGetWalletNotice(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $package = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $controller = new WalletsController(); $method = new \ReflectionMethod($controller, 'getWalletNotice'); $method->setAccessible(true); // User/entitlements created today, balance=0 $notice = $method->invoke($controller, $wallet); $this->assertSame('You are in your free trial period.', $notice); $wallet->owner->created_at = Carbon::now()->subDays(15); $wallet->owner->save(); $notice = $method->invoke($controller, $wallet); $this->assertSame('Your free trial is about to end, top up to continue.', $notice); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $notice = $method->invoke($controller, $wallet); $this->assertSame('You are out of credit, top up your balance now.', $notice); // User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly) $wallet->owner->created_at = Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1); $wallet->owner->save(); // test "1 month" $wallet->balance = 990; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\((1 month|4 weeks)\)/', $notice); // test "2 months" $wallet->balance = 990 * 2.6; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(2 months 2 weeks\)/', $notice); // Change locale to make sure the text is localized by Carbon \app()->setLocale('de'); // test "almost 2 years" $wallet->balance = 990 * 23.5; $notice = $method->invoke($controller, $wallet); $this->assertMatchesRegularExpression('/\(1 Jahr 11 Monate\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::where('discount', 100)->first(); $wallet->discount()->associate($discount); $notice = $method->invoke($controller, $wallet->refresh()); $this->assertSame(null, $notice); } /** * Test fetching pdf receipt */ public function testReceiptDownload(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); $response->assertStatus(403); // Invalid receipt id (current month) $receiptId = date('Y-m'); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Invalid receipt id $receiptId = '1000-03'; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(404); // Valid receipt id $year = intval(date('Y')) - 1; $receiptId = "$year-12"; $filename = \config('app.name') . " Receipt for $year-12"; $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); $response->assertStatus(200); $response->assertHeader('content-type', 'application/pdf'); $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); $response->assertHeader('content-length'); $length = $response->headers->get('content-length'); $content = $response->content(); $this->assertStringStartsWith("%PDF-1.", $content); $this->assertEquals(strlen($content), $length); } /** * Test fetching list of receipts */ public function testReceipts(): void { $user = $this->getTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); $wallet->payments()->delete(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(403); // Empty list expected $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Insert a payment to the database $date = Carbon::create(intval(date('Y')) - 1, 4, 30); $payment = Payment::create([ 'id' => 'AAA1', 'status' => PaymentProvider::STATUS_PAID, 'type' => PaymentProvider::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'currency' => 'CHF', 'currency_amount' => 1111, ]); $payment->updated_at = $date; $payment->save(); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([$date->format('Y-m')], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(1, $json['count']); $this->assertSame(false, $json['hasMore']); } /** * Test fetching a wallet (GET /api/v4/wallets/:id) */ public function testShow(): void { $john = $this->getTestUser('john@kolab.org'); $jack = $this->getTestUser('jack@kolab.org'); $wallet = $john->wallets()->first(); $wallet->balance = -100; $wallet->save(); // Accessing a wallet of someone else $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); $response->assertStatus(403); // Accessing non-existing wallet $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); $response->assertStatus(404); // Wallet owner $response = $this->actingAs($john)->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($wallet->balance, $json['balance']); $this->assertTrue(empty($json['description'])); $this->assertTrue(!empty($json['notice'])); } /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@kolab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame([], $json['list']); $this->assertSame(1, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); + $this->assertSame(\config('app.currency'), $json['list'][$idx]['currency']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index 119977f4..f77ec8cc 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,406 +1,415 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } Sku::select()->update(['fee' => 0]); parent::tearDown(); } /** * Test that turning wallet balance from negative to positive * unsuspends the account */ public function testBalancePositiveUnsuspend(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $user->suspend(); $wallet = $user->wallets()->first(); $wallet->balance = -100; $wallet->save(); $this->assertTrue($user->isSuspended()); $this->assertNotNull($wallet->getSetting('balance_negative_since')); $wallet->balance = 100; $wallet->save(); $this->assertFalse($user->fresh()->isSuspended()); $this->assertNull($wallet->getSetting('balance_negative_since')); // TODO: Test group account and unsuspending domain/members } /** * Test for Wallet::balanceLastsUntil() */ public function testBalanceLastsUntil(): void { // Monthly cost of all entitlements: 990 // 28 days: 35.36 per day // 31 days: 31.93 per day $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); // User/entitlements created today, balance=0 $until = $wallet->balanceLastsUntil(); $this->assertSame( Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(), $until->toDateString() ); // User/entitlements created today, balance=-10 CHF $wallet->balance = -1000; $until = $wallet->balanceLastsUntil(); $this->assertSame(null, $until); // User/entitlements created today, balance=-9,99 CHF (monthly cost) $wallet->balance = 990; $until = $wallet->balanceLastsUntil(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days; $this->assertTrue($delta <= 1); $this->assertTrue($delta >= -1); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first(); $wallet->discount()->associate($discount); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); // User with no entitlements $wallet->discount()->dissociate($discount); $wallet->entitlements()->delete(); $until = $wallet->refresh()->balanceLastsUntil(); $this->assertSame(null, $until); } /** * Test for Wallet::costsPerDay() */ public function testCostsPerDay(): void { // 990 // 28 days: 35.36 // 31 days: 31.93 $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $mailbox = Sku::withEnvTenantContext()->where('title', 'mailbox')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $costsPerDay = $wallet->costsPerDay(); $this->assertTrue($costsPerDay < 35.38); $this->assertTrue($costsPerDay > 31.93); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); + $this->assertSame(\config('app.currency'), $user->wallets[0]->currency); + $this->assertSame(0, $user->wallets[0]->balance); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); + + // For now all wallets use system currency + $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); + // For now additional wallets with a different currency is not allowed + $this->assertFalse($user->wallets()->where('currency', 'USD')->exists()); +/* $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); +*/ } /** * Verify a wallet can be assigned a controller. */ public function testAddWalletController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } /** * Test for charging and removing entitlements (including tenant commission calculations) */ public function testChargeAndDeleteEntitlements(): void { $user = $this->getTestUser('jane@kolabnow.com'); $wallet = $user->wallets()->first(); $discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first(); $wallet->discount()->associate($discount); $wallet->save(); // Add 40% fee to all SKUs Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]); $package = Package::withEnvTenantContext()->where('title', 'kolab')->first(); $storage = Sku::withEnvTenantContext()->where('title', 'storage')->first(); $user->assignPackage($package); $user->assignSku($storage, 5); $user->refresh(); // Reset reseller's wallet balance and transactions $reseller_wallet = $user->tenant->wallet(); $reseller_wallet->balance = 0; $reseller_wallet->save(); Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete(); // ------------------------------------ // Test normal charging of entitlements // ------------------------------------ // Backdate and charge entitlements, we're expecting one month to be charged // Set fake NOW date to make simpler asserting results that depend on number of days in current/last month Carbon::setTestNow(Carbon::create(2021, 5, 21, 12)); $backdate = Carbon::now()->subWeeks(7); $this->backdateEntitlements($user->entitlements, $backdate); $charge = $wallet->chargeEntitlements(); $wallet->refresh(); $reseller_wallet->refresh(); // TODO: Update these comments with what is actually being used to calculate these numbers // 388 + 310 + 17 + 17 = 732 $this->assertSame(-778, $wallet->balance); // 388 - 555 x 40% + 310 - 444 x 40% + 34 - 50 x 40% = 312 $this->assertSame(332, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertCount(1, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(332, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $this->assertCount(1, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-778, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // TODO: Test entitlement transaction records // ----------------------------------- // Test charging on entitlement delete // ----------------------------------- $reseller_wallet->balance = 0; $reseller_wallet->save(); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->delete(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->delete(); $user->removeSku($storage, 2); // we expect the wallet to have been charged for 19 days of use of // 2 deleted storage entitlements $wallet->refresh(); $reseller_wallet->refresh(); // 2 x round(25 / 31 * 19 * 0.7) = 22 $this->assertSame(-(778 + 22), $wallet->balance); // 22 - 2 x round(25 * 0.4 / 31 * 19) = 10 $this->assertSame(10, $reseller_wallet->balance); $transactions = Transaction::where('object_id', $wallet->id) ->where('object_type', \App\Wallet::class)->get(); $reseller_transactions = Transaction::where('object_id', $reseller_wallet->id) ->where('object_type', \App\Wallet::class)->get(); $this->assertCount(2, $reseller_transactions); $trans = $reseller_transactions[0]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(5, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $trans = $reseller_transactions[1]; $this->assertSame("Charged user jane@kolabnow.com", $trans->description); $this->assertSame(5, $trans->amount); $this->assertSame(Transaction::WALLET_CREDIT, $trans->type); $this->assertCount(2, $transactions); $trans = $transactions[0]; $this->assertSame('', $trans->description); $this->assertSame(-11, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); $trans = $transactions[1]; $this->assertSame('', $trans->description); $this->assertSame(-11, $trans->amount); $this->assertSame(Transaction::WALLET_DEBIT, $trans->type); // TODO: Test entitlement transaction records } } diff --git a/src/tests/Unit/WalletTest.php b/src/tests/Unit/WalletTest.php index 67565751..5a1b1f58 100644 --- a/src/tests/Unit/WalletTest.php +++ b/src/tests/Unit/WalletTest.php @@ -1,32 +1,30 @@ 'CHF', - ]); + // This test is here to remind us that the method will give + // different results for different locales + $wallet = new Wallet(['currency' => 'CHF']); $money = $wallet->money(-123); + $this->assertSame('-1,23 CHF', $money); - // This test is here to remind us that the method will give - // different results for different locales, but also depending - // if NumberFormatter (intl extension) is installed or not. - // NumberFormatter also returns some surprising output for - // some locales and e.g. negative numbers. - // We'd have to improve on that as soon as we'd want to use - // other locale than the default de_DE. + $wallet = new Wallet(['currency' => 'EUR']); + $money = $wallet->money(-123); + + $this->assertSame('-1,23 €', $money); } }