diff --git a/src/app/Auth/Utils.php b/src/app/Auth/Utils.php index 6f061da6..ec89c46d 100644 --- a/src/app/Auth/Utils.php +++ b/src/app/Auth/Utils.php @@ -1,100 +1,101 @@ addSeconds(10)->format('YmdHis'); $value = openssl_encrypt($data, $cipher, $key, 0, $iv, $tag); if ($value === false) { return null; } return trim(base64_encode($iv), '=') . '!' . trim(base64_encode($tag), '=') . '!' . trim(base64_encode($value), '='); } /** * Vaidate a simple authentication token * * @param string $token Token * * @return string|null User identifier, Null on failure */ public static function tokenValidate($token): ?string { if (!preg_match('|^[a-zA-Z0-9!+/]{50,}$|', $token)) { // this isn't a token, probably a normal password return null; } [$iv, $tag, $payload] = explode('!', $token); $iv = base64_decode($iv); $tag = base64_decode($tag); $payload = base64_decode($payload); $cipher = strtolower(config('app.cipher')); $key = config('app.key'); $decrypted = openssl_decrypt($payload, $cipher, $key, 0, $iv, $tag); if ($decrypted === false) { return null; } $payload = explode('!', $decrypted); - if (count($payload) != 2 + if ( + count($payload) != 2 || !preg_match('|^[0-9]+$|', $payload[0]) || !preg_match('|^[0-9]{14}+$|', $payload[1]) ) { // Invalid payload format return null; } // Check expiration date try { $expiry = Carbon::create( (int) substr($payload[1], 0, 4), (int) substr($payload[1], 4, 2), (int) substr($payload[1], 6, 2), (int) substr($payload[1], 8, 2), (int) substr($payload[1], 10, 2), (int) substr($payload[1], 12, 2) ); if (now() > $expiry) { return null; } } catch (\Exception $e) { return null; } return $payload[0]; } } diff --git a/src/app/Console/Commands/Tenant/CreateCommand.php b/src/app/Console/Commands/Tenant/CreateCommand.php index 547b601e..0e7f3669 100644 --- a/src/app/Console/Commands/Tenant/CreateCommand.php +++ b/src/app/Console/Commands/Tenant/CreateCommand.php @@ -1,192 +1,192 @@ argument('user'); if ($user = \App\User::where('email', $email)->first()) { $this->error("The user already exists."); return 1; } if ($domain = \App\Domain::where('namespace', $this->argument('domain'))->first()) { $this->error("The domain already exists."); return 1; } DB::beginTransaction(); // Create a tenant $tenant = \App\Tenant::create(['title' => $this->option('title')]); // Clone plans, packages, skus for the tenant $sku_map = \App\Sku::withEnvTenantContext()->where('active', true)->get() ->mapWithKeys(function ($sku) use ($tenant) { $sku_new = \App\Sku::create([ 'title' => $sku->title, 'name' => $sku->getTranslations('name'), 'description' => $sku->getTranslations('description'), 'cost' => $sku->cost, 'units_free' => $sku->units_free, 'period' => $sku->period, 'handler_class' => $sku->handler_class, 'active' => true, 'fee' => $sku->fee, ]); $sku_new->tenant_id = $tenant->id; $sku_new->save(); return [$sku->id => $sku_new->id]; }) ->all(); $plan_map = \App\Plan::withEnvTenantContext()->get() ->mapWithKeys(function ($plan) use ($tenant) { $plan_new = \App\Plan::create([ 'title' => $plan->title, 'name' => $plan->getTranslations('name'), 'description' => $plan->getTranslations('description'), 'promo_from' => $plan->promo_from, 'promo_to' => $plan->promo_to, 'qty_min' => $plan->qty_min, 'qty_max' => $plan->qty_max, 'discount_qty' => $plan->discount_qty, 'discount_rate' => $plan->discount_rate, ]); $plan_new->tenant_id = $tenant->id; $plan_new->save(); return [$plan->id => $plan_new->id]; }) ->all(); $package_map = \App\Package::withEnvTenantContext()->get() ->mapWithKeys(function ($package) use ($tenant) { $package_new = \App\Package::create([ 'title' => $package->title, 'name' => $package->getTranslations('name'), 'description' => $package->getTranslations('description'), 'discount_rate' => $package->discount_rate, ]); $package_new->tenant_id = $tenant->id; $package_new->save(); return [$package->id => $package_new->id]; }) ->all(); DB::table('package_skus')->whereIn('package_id', array_keys($package_map))->get() ->each(function ($item) use ($package_map, $sku_map) { if (isset($sku_map[$item->sku_id])) { DB::table('package_skus')->insert([ 'qty' => $item->qty, 'cost' => $item->cost, 'sku_id' => $sku_map[$item->sku_id], 'package_id' => $package_map[$item->package_id], ]); } }); DB::table('plan_packages')->whereIn('plan_id', array_keys($plan_map))->get() ->each(function ($item) use ($package_map, $plan_map) { if (isset($package_map[$item->package_id])) { DB::table('plan_packages')->insert([ 'qty' => $item->qty, 'qty_min' => $item->qty_min, 'qty_max' => $item->qty_max, 'discount_qty' => $item->discount_qty, 'discount_rate' => $item->discount_rate, 'plan_id' => $plan_map[$item->plan_id], 'package_id' => $package_map[$item->package_id], ]); } }); // Disable jobs, they would fail anyway as the TENANT_ID is different // TODO: We could probably do config(['app.tenant' => $tenant->id]) here Queue::fake(); // Make sure the transaction wasn't aborted $tenant = \App\Tenant::find($tenant->id); if (!$tenant) { $this->error("Failed to create a tenant."); return 1; } $this->info("Created tenant {$tenant->id}."); // Set up the primary tenant domain $domain = \App\Domain::create( [ 'namespace' => $this->argument('domain'), 'type' => \App\Domain::TYPE_PUBLIC, ] ); $domain->tenant_id = $tenant->id; $domain->status = \App\Domain::STATUS_CONFIRMED | \App\Domain::STATUS_ACTIVE; $domain->save(); $this->info("Created domain {$domain->id}."); $user = new \App\User(); $user->email = $email; $user->password = $this->option('password'); $user->role = 'reseller'; $user->tenant_id = $tenant->id; if ($error = UsersController::validateEmail($email, $user)) { $this->error("{$email}: {$error}"); return 1; } $user->save(); $this->info("Created user {$user->id}."); $tenant->setSettings([ "app.name" => $this->option("title"), "app.url" => $this->argument("domain"), "app.public_url" => "https://" . $this->argument("domain"), "app.support_url" => "https://" . $this->argument("domain") . "/support", "mail.sender.address" => "noreply@" . $this->argument("domain"), "mail.sender.name" => $this->option("title"), "mail.replyto.address" => "noreply@" . $this->argument("domain"), "mail.replyto.name" => $this->option("title"), ]); DB::commit(); $this->info("Applied default tenant settings."); } } diff --git a/src/app/Console/ObjectCreateCommand.php b/src/app/Console/ObjectCreateCommand.php index 8914b73d..16789880 100644 --- a/src/app/Console/ObjectCreateCommand.php +++ b/src/app/Console/ObjectCreateCommand.php @@ -1,85 +1,86 @@ description = "Create a {$this->objectName}"; $this->signature = sprintf( "%s%s:create", $this->commandPrefix ? $this->commandPrefix . ":" : "", $this->objectName ); foreach ($this->getClassProperties() as $fillable) { $this->signature .= " {--{$fillable}=}"; } parent::__construct(); } /** * Return list of fillable properties for the specified object type */ protected function getClassProperties(): array { $class = new $this->objectClass(); $properties = $class->getFillable(); - if ($this->commandPrefix == 'scalpel' + if ( + $this->commandPrefix == 'scalpel' && in_array(\App\Traits\BelongsToTenantTrait::class, class_uses($this->objectClass)) ) { $properties[] = 'tenant_id'; } return $properties; } /** * Return object properties from the input */ protected function getProperties(): array { if (is_array($this->properties)) { return $this->properties; } $this->properties = []; foreach ($this->getClassProperties() as $fillable) { $this->properties[$fillable] = $this->option($fillable); } return $this->properties; } /** * Execute the console command. */ public function handle() { $object = new $this->objectClass(); try { foreach ($this->getProperties() as $name => $value) { $object->{$name} = $value; } $object->save(); $this->info($object->{$object->getKeyName()}); } catch (\Exception $e) { $this->error("Object could not be created."); return 1; } } } diff --git a/src/app/Http/Controllers/API/V4/SearchController.php b/src/app/Http/Controllers/API/V4/SearchController.php index 4ec70ebf..4f8f208b 100644 --- a/src/app/Http/Controllers/API/V4/SearchController.php +++ b/src/app/Http/Controllers/API/V4/SearchController.php @@ -1,159 +1,162 @@ guard()->user(); $search = trim(request()->input('search')); $with_aliases = !empty(request()->input('alias')); $limit = intval(request()->input('limit')); if ($limit <= 0) { $limit = 15; } elseif ($limit > 100) { $limit = 100; } // Prepare the query $query = User::select('email', 'id')->where('id', $user->id); $aliases = DB::table('user_aliases')->select(DB::raw('alias as email, user_id as id')) ->where('user_id', $user->id); if (strlen($search)) { $aliases->whereLike('alias', $search); $query->whereLike('email', $search); } if ($with_aliases) { $query->union($aliases); } // Execute the query $result = $query->orderBy('email')->limit($limit)->get(); $result = $this->resultFormat($result); return response()->json([ 'list' => $result, 'count' => count($result), ]); } /** * Search request for addresses of all users (in an account) * * @param \Illuminate\Http\Request $request The API request. * * @return \Illuminate\Http\JsonResponse The response */ public function searchUser(Request $request) { $user = $this->guard()->user(); $search = trim(request()->input('search')); $with_aliases = !empty(request()->input('alias')); $limit = intval(request()->input('limit')); if ($limit <= 0) { $limit = 15; } elseif ($limit > 100) { $limit = 100; } $wallet = $user->wallet(); // Limit users to the user's account - $allUsers = $wallet->entitlements()->where('entitleable_type', User::class)->select('entitleable_id')->distinct(); + $allUsers = $wallet->entitlements() + ->where('entitleable_type', User::class) + ->select('entitleable_id') + ->distinct(); // Sub-query for user IDs who's names match the search criteria $foundUserIds = UserSetting::select('user_id') ->whereIn('key', ['first_name', 'last_name']) ->whereLike('value', $search) ->whereIn('user_id', $allUsers); // Prepare the query $query = User::select('email', 'id')->whereIn('id', $allUsers); $aliases = DB::table('user_aliases')->select(DB::raw('alias as email, user_id as id')) ->whereIn('user_id', $allUsers); if (strlen($search)) { $query->where(function ($query) use ($foundUserIds, $search) { $query->whereLike('email', $search) ->orWhereIn('id', $foundUserIds); }); $aliases->where(function ($query) use ($foundUserIds, $search) { $query->whereLike('alias', $search) ->orWhereIn('user_id', $foundUserIds); }); } if ($with_aliases) { $query->union($aliases); } // Execute the query $result = $query->orderBy('email')->limit($limit)->get(); $result = $this->resultFormat($result); return response()->json([ 'list' => $result, 'count' => count($result), ]); } /** * Format the search result, inject user names */ protected function resultFormat($result) { if ($result->count()) { // Get user names $settings = UserSetting::whereIn('key', ['first_name', 'last_name']) ->whereIn('user_id', $result->pluck('id')) ->get() ->mapWithKeys(function ($item) { return [($item->user_id . ':' . $item->key) => $item->value]; }) ->all(); // "Format" the result, include user names $result = $result->map(function ($record) use ($settings) { return [ 'email' => $record->email, 'name' => trim( ($settings["{$record->id}:first_name"] ?? '') . ' ' . ($settings["{$record->id}:last_name"] ?? '') ), ]; }) ->sortBy(['name', 'email']) ->values(); } return $result; } } diff --git a/src/app/Jobs/WalletCheck.php b/src/app/Jobs/WalletCheck.php index 080721ef..cf246138 100644 --- a/src/app/Jobs/WalletCheck.php +++ b/src/app/Jobs/WalletCheck.php @@ -1,293 +1,293 @@ walletId = $walletId; } /** * Execute the job. * * @return ?string Executed action (THRESHOLD_*) */ public function handle() { $this->wallet = Wallet::find($this->walletId); // Sanity check (owner deleted in meantime) if (!$this->wallet || !$this->wallet->owner) { return null; } if ($this->wallet->chargeEntitlements() > 0) { - // We make a payment when there's a charge. If for some reason the + // We make a payment when there's a charge. If for some reason the // payment failed we can't just throw here, as another execution of this job // will not re-try the payment. So, we attempt a payment in a separate job. try { $this->topUpWallet(); } catch (\Exception $e) { \Log::error("Failed to top-up wallet {$this->walletId}: " . $e->getMessage()); WalletCharge::dispatch($this->wallet->id); } } if ($this->wallet->balance >= 0) { return null; } $now = Carbon::now(); $steps = [ // Send the initial reminder self::THRESHOLD_INITIAL => 'initialReminderForDegrade', // Try to top-up the wallet before the second reminder self::THRESHOLD_BEFORE_REMINDER => 'topUpWallet', // Send the second reminder self::THRESHOLD_REMINDER => 'secondReminderForDegrade', // Try to top-up the wallet before the account degradation self::THRESHOLD_BEFORE_DEGRADE => 'topUpWallet', // Degrade the account self::THRESHOLD_DEGRADE => 'degradeAccount', ]; if ($this->wallet->owner && $this->wallet->owner->isDegraded()) { $this->degradedReminder(); return self::THRESHOLD_DEGRADE_REMINDER; } foreach (array_reverse($steps, true) as $type => $method) { if (self::threshold($this->wallet, $type) < $now) { $this->{$method}(); return $type; } } return null; } /** * Send the initial reminder (for the process of degrading a account) */ protected function initialReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_initial')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } if (!$this->wallet->owner->isSuspended()) { $this->sendMail(\App\Mail\NegativeBalance::class, false); } $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_initial', $now); } /** * Send the second reminder (for the process of degrading a account) */ protected function secondReminderForDegrade() { if ($this->wallet->getSetting('balance_warning_reminder')) { return; } if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } if (!$this->wallet->owner->isSuspended()) { $this->sendMail(\App\Mail\NegativeBalanceReminderDegrade::class, true); } $now = \Carbon\Carbon::now()->toDateTimeString(); $this->wallet->setSetting('balance_warning_reminder', $now); } /** * Degrade the account */ protected function degradeAccount() { // The account may be already deleted, or degraded if (!$this->wallet->owner || $this->wallet->owner->isDegraded()) { return; } $email = $this->wallet->owner->email; // The dirty work will be done by UserObserver $this->wallet->owner->degrade(); \Log::info( sprintf( "[WalletCheck] Account degraded %s (%s)", $this->wallet->id, $email ) ); if (!$this->wallet->owner->isSuspended()) { $this->sendMail(\App\Mail\NegativeBalanceDegraded::class, true); } } /** * Send the periodic reminder to the degraded account owners */ protected function degradedReminder() { // Sanity check if (!$this->wallet->owner || !$this->wallet->owner->isDegraded()) { return; } if ($this->wallet->owner->isSuspended()) { return; } $now = \Carbon\Carbon::now(); $last = $this->wallet->getSetting('degraded_last_reminder'); if ($last) { $last = new Carbon($last); $period = 14; if ($last->addDays($period) > $now) { return; } $this->sendMail(\App\Mail\DegradedAccountReminder::class, false); } $this->wallet->setSetting('degraded_last_reminder', $now->toDateTimeString()); } /** * Send the email * * @param string $class Mailable class name * @param bool $with_external Use users's external email */ protected function sendMail($class, $with_external = false): void { // TODO: Send the email to all wallet controllers? $mail = new $class($this->wallet, $this->wallet->owner); list($to, $cc) = \App\Mail\Helper::userEmails($this->wallet->owner, $with_external); if (!empty($to) || !empty($cc)) { $params = [ 'to' => $to, 'cc' => $cc, 'add' => " for {$this->wallet->id}", ]; \App\Mail\Helper::sendMail($mail, $this->wallet->owner->tenant_id, $params); } } /** * Get the date-time for an action threshold. Calculated using * the date when a wallet balance turned negative. * * @param \App\Wallet $wallet A wallet * @param string $type Action type (one of self::THRESHOLD_*) * * @return \Carbon\Carbon The threshold date-time object */ public static function threshold(Wallet $wallet, string $type): ?Carbon { $negative_since = $wallet->getSetting('balance_negative_since'); // Migration scenario: balance<0, but no balance_negative_since set if (!$negative_since) { // 2h back from now, so first run can sent the initial notification $negative_since = Carbon::now()->subHours(2); $wallet->setSetting('balance_negative_since', $negative_since->toDateTimeString()); } else { $negative_since = new Carbon($negative_since); } // Initial notification // Give it an hour so the async recurring payment has a chance to be finished if ($type == self::THRESHOLD_INITIAL) { return $negative_since->addHours(1); } $thresholds = [ // A day before the second reminder self::THRESHOLD_BEFORE_REMINDER => 7 - 1, // Second notification self::THRESHOLD_REMINDER => 7, // Last chance to top-up the wallet self::THRESHOLD_BEFORE_DEGRADE => 13, // Account degradation self::THRESHOLD_DEGRADE => 14, ]; if (!empty($thresholds[$type])) { return $negative_since->addDays($thresholds[$type]); } return null; } /** * Try to automatically top-up the wallet */ protected function topUpWallet(): void { PaymentsController::topUpWallet($this->wallet); } } diff --git a/src/database/migrations/2020_05_05_095212_create_tenants_table.php b/src/database/migrations/2020_05_05_095212_create_tenants_table.php index 4903c140..fdc4275e 100644 --- a/src/database/migrations/2020_05_05_095212_create_tenants_table.php +++ b/src/database/migrations/2020_05_05_095212_create_tenants_table.php @@ -1,78 +1,75 @@ bigIncrements('id'); $table->string('title', 32); $table->timestamps(); } ); - - foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $tableName) { Schema::table( $tableName, function (Blueprint $table) { $table->bigInteger('tenant_id')->unsigned()->nullable(); $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('set null'); } ); - } // Add fee column foreach (['entitlements', 'skus'] as $table) { Schema::table( $table, function (Blueprint $table) { $table->integer('fee')->nullable(); } ); } } /** * Reverse the migrations. * * @return void */ public function down() { foreach (['users', 'discounts', 'domains', 'plans', 'packages', 'skus'] as $tableName) { Schema::table( $tableName, function (Blueprint $table) { $table->dropForeign(['tenant_id']); $table->dropColumn('tenant_id'); } ); } foreach (['entitlements', 'skus'] as $table) { Schema::table( $table, function (Blueprint $table) { $table->dropColumn('fee'); } ); } Schema::dropIfExists('tenants'); } } diff --git a/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php b/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php index 8a7bdf07..dbe03d99 100644 --- a/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php +++ b/src/tests/Feature/Console/Scalpel/Domain/CreateCommandTest.php @@ -1,64 +1,64 @@ deleteTestDomain('domain-delete.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestDomain('domain-delete.com'); parent::tearDown(); } /** * Test the command execution */ public function testHandle(): void { // Test --help argument $code = \Artisan::call("scalpel:domain:create --help"); $output = trim(\Artisan::output()); $this->assertSame(0, $code); $this->assertStringContainsString('--namespace[=NAMESPACE]', $output); $this->assertStringContainsString('--type[=TYPE]', $output); $this->assertStringContainsString('--status[=STATUS]', $output); $this->assertStringContainsString('--tenant_id[=TENANT_ID]', $output); $tenant = \App\Tenant::orderBy('id', 'desc')->first(); // Test successful domain creation $code = \Artisan::call("scalpel:domain:create" . " --namespace=domain-delete.com" . " --type=" . Domain::TYPE_PUBLIC . " --tenant_id={$tenant->id}" - ); + ); $output = trim(\Artisan::output()); $domain = $this->getTestDomain('domain-delete.com'); $this->assertSame(0, $code); $this->assertSame($output, (string) $domain->id); $this->assertSame('domain-delete.com', $domain->namespace); $this->assertSame(Domain::TYPE_PUBLIC, $domain->type); $this->assertSame($domain->tenant_id, $tenant->id); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 6b1e1559..8a30bd89 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,356 +1,357 @@ 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'); $plan = \App\Plan::withObjectTenantContext($user)->where('title', 'individual')->first(); $user->assignPlan($plan); $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()->subWeeks(3); $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) $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1)); $wallet->refresh(); // 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('/\(1 month 4 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 10 Monate\)/', $notice); // Old entitlements, 100% discount $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); $discount = \App\Discount::withObjectTenantContext($user)->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' => Payment::STATUS_PAID, 'type' => Payment::TYPE_ONEOFF, 'description' => 'Paid in April', 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, 'credit_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(); + $expected = ['period' => $date->format('Y-m'), 'amount' => '1111', 'currency' => 'CHF']; $this->assertCount(5, $json); $this->assertSame('success', $json['status']); - $this->assertSame(['period' => $date->format('Y-m'), 'amount' => '1111', 'currency' => 'CHF'], $json['list'][0]); + $this->assertSame($expected, $json['list'][0]); $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); } }