diff --git a/src/app/Console/Commands/WalletTransactions.php b/src/app/Console/Commands/WalletTransactions.php index ae45b0ce..c6e2736a 100644 --- a/src/app/Console/Commands/WalletTransactions.php +++ b/src/app/Console/Commands/WalletTransactions.php @@ -1,73 +1,73 @@ argument('wallet'))->first(); if (!$wallet) { return 1; } - foreach ($wallet->transactions() as $transaction) { + foreach ($wallet->transactions()->orderBy('created_at')->get() as $transaction) { $this->info( sprintf( "%s: %s %s", $transaction->id, $transaction->created_at, $transaction->toString() ) ); if ($this->option('detail')) { $elements = \App\Transaction::where('transaction_id', $transaction->id) ->orderBy('created_at')->get(); foreach ($elements as $element) { $this->info( sprintf( " + %s: %s %s", $element->id, $element->created_at, $element->toString() ) ); } } } } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index 154b5e21..7c6917b6 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,93 +1,172 @@ 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); } /** * Display the specified resource. * * @param string $id * * @return \Illuminate\Http\JsonResponse */ public function show($id) { return $this->errorResponse(404); } /** * 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 int $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); } + + /** + * Fetch wallet transactions. + * + * @param string $id Wallet identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function transactions($id) + { + $wallet = Wallet::find($id); + + // Only owner (or admin) has access to the wallet + if (!Auth::guard()->user()->canRead($wallet)) { + return $this->errorResponse(403); + } + + $pageSize = 10; + $page = intval(request()->input('page')) ?: 1; + $hasMore = false; + + 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) { + $amount = $item->amount; + + if (in_array($item->type, [Transaction::WALLET_PENALTY, Transaction::WALLET_DEBIT])) { + $amount *= -1; + } + + return [ + 'id' => $item->id, + 'createdAt' => $item->created_at->format('Y-m-d H:i'), + 'type' => $item->type, + 'description' => $item->shortDescription(), + 'amount' => $amount, + 'hasDetails' => !empty($item->cnt), + ]; + }); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + 'page' => $page, + ]); + } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php index 5472f503..a97c78dd 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,207 +1,226 @@ 'integer', ]; /** @var boolean This model uses an automatically incrementing integer primary key? */ public $incrementing = false; /** @var string The type of the primary key */ protected $keyType = 'string'; public const ENTITLEMENT_BILLED = 'billed'; public const ENTITLEMENT_CREATED = 'created'; public const ENTITLEMENT_DELETED = 'deleted'; public const WALLET_AWARD = 'award'; public const WALLET_CREDIT = 'credit'; public const WALLET_DEBIT = 'debit'; public const WALLET_PENALTY = 'penalty'; public function entitlement() { if ($this->object_type !== \App\Entitlement::class) { return null; } return \App\Entitlement::withTrashed()->where('id', $this->object_id)->first(); } public function setTypeAttribute($value) { switch ($value) { case self::ENTITLEMENT_BILLED: case self::ENTITLEMENT_CREATED: case self::ENTITLEMENT_DELETED: // TODO: Must be an entitlement. $this->attributes['type'] = $value; break; case self::WALLET_AWARD: case self::WALLET_CREDIT: case self::WALLET_DEBIT: case self::WALLET_PENALTY: // TODO: This must be a wallet. $this->attributes['type'] = $value; break; default: throw new \Exception("Invalid type value"); } } public function toArray() { $result = [ 'user_email' => $this->user_email, 'entitlement_cost' => $this->getEntitlementCost(), 'object_email' => $this->getEntitlementObjectEmail(), 'sku_title' => $this->getEntitlementSkuTitle(), 'wallet_description' => $this->getWalletDescription(), 'description' => $this->{'description'}, 'amount' => $this->amount ]; return $result; } public function toString() { $label = $this->objectTypeToLabelString() . '-' . $this->{'type'}; return \trans("transactions.{$label}", $this->toArray()); } + public function shortDescription() + { + $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; + + return \trans("transactions.{$label}", $this->toArray()); + } + public function wallet() { if ($this->object_type !== \App\Wallet::class) { return null; } return \App\Wallet::where('id', $this->object_id)->first(); } /** * Return the costs for this entitlement. * * @return int|null */ private function getEntitlementCost(): ?int { if (!$this->entitlement()) { return null; } // FIXME: without wallet discount // FIXME: in cents // FIXME: without wallet currency $cost = $this->entitlement()->cost; $discount = $this->entitlement()->wallet->getDiscountRate(); return $cost * $discount; } /** * Return the object email if any. This is the email for the target user entitlement. * * @return string|null */ private function getEntitlementObjectEmail(): ?string { $entitlement = $this->entitlement(); if (!$entitlement) { return null; } $entitleable = $entitlement->entitleable; if (!$entitleable) { \Log::debug("No entitleable for {$entitlement->id} ?"); return null; } return $entitleable->email; } /** * Return the title for the SKU this entitlement is for. * * @return string|null */ private function getEntitlementSkuTitle(): ?string { if (!$this->entitlement()) { return null; } return $this->entitlement()->sku->{'title'}; } /** * Return the description for the wallet, if any, or 'default wallet'. * * @return string */ public function getWalletDescription() { $description = null; if ($entitlement = $this->entitlement()) { $description = $entitlement->wallet->{'description'}; } if ($wallet = $this->wallet()) { $description = $wallet->{'description'}; } return $description ?: 'Default wallet'; } /** * Get a string for use in translation tables derived from the object type. * * @return string|null */ private function objectTypeToLabelString(): ?string { if ($this->object_type == \App\Entitlement::class) { return 'entitlement'; } if ($this->object_type == \App\Wallet::class) { return 'wallet'; } return null; } } diff --git a/src/app/User.php b/src/app/User.php index cb2ea428..d7ce616a 100644 --- a/src/app/User.php +++ b/src/app/User.php @@ -1,621 +1,625 @@ belongsToMany( 'App\Wallet', // The foreign object definition 'user_accounts', // The table name 'user_id', // The local foreign key 'wallet_id' // The remote foreign key ); } /** * Email aliases of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function aliases() { return $this->hasMany('App\UserAlias', 'user_id'); } /** * Assign a package to a user. The user should not have any existing entitlements. * * @param \App\Package $package The package to assign. * @param \App\User|null $user Assign the package to another user. * * @return \App\User */ public function assignPackage($package, $user = null) { if (!$user) { $user = $this; } $wallet_id = $this->wallets()->first()->id; foreach ($package->skus as $sku) { for ($i = $sku->pivot->qty; $i > 0; $i--) { \App\Entitlement::create( [ 'wallet_id' => $wallet_id, 'sku_id' => $sku->id, 'cost' => $sku->pivot->cost(), 'entitleable_id' => $user->id, 'entitleable_type' => User::class ] ); } } return $user; } /** * Assign a package plan to a user. * * @param \App\Plan $plan The plan to assign * @param \App\Domain $domain Optional domain object * * @return \App\User Self */ public function assignPlan($plan, $domain = null): User { $this->setSetting('plan_id', $plan->id); foreach ($plan->packages as $package) { if ($package->isDomain()) { $domain->assignPackage($package, $this); } else { $this->assignPackage($package); } } return $this; } /** * Assign a Sku to a user. * * @param \App\Sku $sku The sku to assign. * @param int $count Count of entitlements to add * * @return \App\User Self * @throws \Exception */ public function assignSku($sku, int $count = 1): User { // TODO: I guess wallet could be parametrized in future $wallet = $this->wallet(); $exists = $this->entitlements()->where('sku_id', $sku->id)->count(); // TODO: Sanity check, this probably should be in preReq() on handlers // or in EntitlementObserver if ($sku->handler_class::entitleableClass() != User::class) { throw new \Exception("Cannot assign non-user SKU ({$sku->title}) to a user"); } while ($count > 0) { \App\Entitlement::create([ 'wallet_id' => $wallet->id, 'sku_id' => $sku->id, 'cost' => $sku->units_free >= $exists ? $sku->cost : 0, 'entitleable_id' => $this->id, 'entitleable_type' => User::class ]); $exists++; $count--; } return $this; } /** * Check if current user can delete another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canDelete($object): bool { if (!method_exists($object, 'wallet')) { return false; } $wallet = $object->wallet(); // TODO: For now controller can delete/update the account owner, // this may change in future, controllers are not 0-regression feature return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can read data of another object. * - * @param \App\User|\App\Domain $object A user|domain object + * @param \App\User|\App\Domain|\App\Wallet $object A user|domain|wallet object * * @return bool True if he can, False otherwise */ public function canRead($object): bool { - if (!method_exists($object, 'wallet')) { - return false; - } - if ($this->role == "admin") { return true; } if ($object instanceof User && $this->id == $object->id) { return true; } + if ($object instanceof Wallet) { + return $object->user_id == $this->id || $object->controllers->contains($this); + } + + if (!method_exists($object, 'wallet')) { + return false; + } + $wallet = $object->wallet(); return $this->wallets->contains($wallet) || $this->accounts->contains($wallet); } /** * Check if current user can update data of another object. * * @param \App\User|\App\Domain $object A user|domain object * * @return bool True if he can, False otherwise */ public function canUpdate($object): bool { if (!method_exists($object, 'wallet')) { return false; } if ($object instanceof User && $this->id == $object->id) { return true; } return $this->canDelete($object); } /** * List the domains to which this user is entitled. * * @return Domain[] */ public function domains() { $dbdomains = Domain::whereRaw( sprintf( '(type & %s) AND (status & %s)', Domain::TYPE_PUBLIC, Domain::STATUS_ACTIVE ) )->get(); $domains = []; foreach ($dbdomains as $dbdomain) { $domains[] = $dbdomain; } foreach ($this->wallets as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain for {$this->email}: {$domain->namespace} (owned)"); $domains[] = $domain; } } foreach ($this->accounts as $wallet) { $entitlements = $wallet->entitlements()->where('entitleable_type', Domain::class)->get(); foreach ($entitlements as $entitlement) { $domain = $entitlement->entitleable; \Log::info("Found domain {$this->email}: {$domain->namespace} (charged)"); $domains[] = $domain; } } return $domains; } public function entitlement() { return $this->morphOne('App\Entitlement', 'entitleable'); } /** * Entitlements for this user. * * Note that these are entitlements that apply to the user account, and not entitlements that * this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement', 'entitleable_id', 'id'); } public function addEntitlement($entitlement) { if (!$this->entitlements->contains($entitlement)) { return $this->entitlements()->save($entitlement); } } /** * Helper to find user by email address, whether it is * main email address, alias or external email * * @param string $email Email address * @param bool $external Search also by an external email * * @return \App\User User model object if found */ public static function findByEmail(string $email, bool $external = false): ?User { if (strpos($email, '@') === false) { return null; } $email = \strtolower($email); $user = self::where('email', $email)->first(); if ($user) { return $user; } $alias = UserAlias::where('alias', $email)->first(); if ($alias) { return $alias->user; } // TODO: External email return null; } public function getJWTIdentifier() { return $this->getKey(); } public function getJWTCustomClaims() { return []; } /** * Returns whether this domain is active. * * @return bool */ public function isActive(): bool { return ($this->status & self::STATUS_ACTIVE) > 0; } /** * Returns whether this domain is deleted. * * @return bool */ public function isDeleted(): bool { return ($this->status & self::STATUS_DELETED) > 0; } /** * Returns whether this (external) domain has been verified * to exist in DNS. * * @return bool */ public function isImapReady(): bool { return ($this->status & self::STATUS_IMAP_READY) > 0; } /** * Returns whether this user is registered in LDAP. * * @return bool */ public function isLdapReady(): bool { return ($this->status & self::STATUS_LDAP_READY) > 0; } /** * Returns whether this user is new. * * @return bool */ public function isNew(): bool { return ($this->status & self::STATUS_NEW) > 0; } /** * Returns whether this domain is suspended. * * @return bool */ public function isSuspended(): bool { return ($this->status & self::STATUS_SUSPENDED) > 0; } /** * A shortcut to get the user name. * * @param bool $fallback Return " User" if there's no name * * @return string Full user name */ public function name(bool $fallback = false): string { $firstname = $this->getSetting('first_name'); $lastname = $this->getSetting('last_name'); $name = trim($firstname . ' ' . $lastname); if (empty($name) && $fallback) { return \config('app.name') . ' User'; } return $name; } /** * Any (additional) properties of this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\UserSetting', 'user_id'); } /** * Suspend this domain. * * @return void */ public function suspend(): void { if ($this->isSuspended()) { return; } $this->status |= User::STATUS_SUSPENDED; $this->save(); } /** * Unsuspend this domain. * * @return void */ public function unsuspend(): void { if (!$this->isSuspended()) { return; } $this->status ^= User::STATUS_SUSPENDED; $this->save(); } /** * Return users controlled by the current user. * * @param bool $with_accounts Include users assigned to wallets * the current user controls but not owns. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function users($with_accounts = true) { $wallets = $this->wallets()->pluck('id')->all(); if ($with_accounts) { $wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all()); } return $this->select(['users.*', 'entitlements.wallet_id']) ->distinct() ->leftJoin('entitlements', 'entitlements.entitleable_id', '=', 'users.id') ->whereIn('entitlements.wallet_id', $wallets) ->where('entitlements.entitleable_type', 'App\User'); } /** * Verification codes for this user. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function verificationcodes() { return $this->hasMany('App\VerificationCode', 'user_id', 'id'); } /** * Returns the wallet by which the user is controlled * * @return \App\Wallet A wallet object */ public function wallet(): Wallet { $entitlement = $this->entitlement()->first(); // TODO: No entitlement should not happen, but in tests we have // such cases, so we fallback to the user's wallet in this case return $entitlement ? $entitlement->wallet : $this->wallets()->first(); } /** * Wallets this user owns. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function wallets() { return $this->hasMany('App\Wallet'); } /** * User password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User LDAP password mutator * * @param string $password The password in plain text. * * @return void */ public function setPasswordLdapAttribute($password) { if (!empty($password)) { $this->attributes['password'] = bcrypt($password, [ "rounds" => 12 ]); $this->attributes['password_ldap'] = '{SSHA512}' . base64_encode( pack('H*', hash('sha512', $password)) ); } } /** * User status mutator * * @throws \Exception */ public function setStatusAttribute($status) { $new_status = 0; $allowed_values = [ self::STATUS_NEW, self::STATUS_ACTIVE, self::STATUS_SUSPENDED, self::STATUS_DELETED, self::STATUS_LDAP_READY, self::STATUS_IMAP_READY, ]; foreach ($allowed_values as $value) { if ($status & $value) { $new_status |= $value; $status ^= $value; } } if ($status > 0) { throw new \Exception("Invalid user status: {$status}"); } $this->attributes['status'] = $new_status; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index c2553525..562e1258 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,327 +1,329 @@ 0, 'currency' => 'CHF' ]; protected $fillable = [ 'currency' ]; protected $nullable = [ 'description', ]; protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } public function chargeEntitlements($apply = true) { $charges = 0; $discount = $this->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; } // created more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $charges += $cost; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( \App\Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, $entitlementTransactions); } DB::commit(); return $charges; } /** * 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::DECIMAL); - return $nf->formatCurrency($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); } return sprintf('%.2f %s', $amount, $this->currency); } /** * 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 ); } /** * 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( [ 'user_email' => \App\Utils::userEmailOrNull(), '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 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, array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = \App\Transaction::create( [ 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, 'amount' => $amount ] ); \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; } /** * 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 iterable \App\Transaction + * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return \App\Transaction::where( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class ] - )->orderBy('created_at')->get(); + ); } } diff --git a/src/phpstan.neon b/src/phpstan.neon index eff2ef46..718418bd 100644 --- a/src/phpstan.neon +++ b/src/phpstan.neon @@ -1,13 +1,16 @@ includes: - ./vendor/nunomaduro/larastan/extension.neon parameters: excludes_analyse: - tests/Browser inferPrivatePropertyTypeFromConstructor: true ignoreErrors: - '#Access to an undefined property Illuminate\\Contracts\\Auth\\Authenticatable#' - '#Access to an undefined property App\\Package::\$pivot#' + - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$id#' + - '#Access to an undefined property Illuminate\\Database\\Eloquent\\Model::\$created_at#' + - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Model::toString()#' level: 4 paths: - app/ - tests/ diff --git a/src/resources/lang/en/transactions.php b/src/resources/lang/en/transactions.php index a77cedfc..d83320bb 100644 --- a/src/resources/lang/en/transactions.php +++ b/src/resources/lang/en/transactions.php @@ -1,12 +1,21 @@ ':user_email created :sku_title for :object_email', 'entitlement-billed' => ':sku_title for :object_email is billed at :amount', 'entitlement-deleted' => ':user_email deleted :sku_title for :object_email', 'wallet-award' => 'Bonus of :amount awarded to :wallet_description; :description', 'wallet-credit' => ':amount was added to the balance of :wallet_description', 'wallet-debit' => ':amount was deducted from the balance of :wallet_description', - 'wallet-penalty' => 'The balance of wallet :wallet_description was reduced by :amount; :description' + 'wallet-penalty' => 'The balance of wallet :wallet_description was reduced by :amount; :description', + + 'entitlement-created-short' => 'Added :sku_title for :object_email', + 'entitlement-billed-short' => 'Billed :sku_title for :object_email', + 'entitlement-deleted-short' => 'Deleted :sku_title for :object_email', + + 'wallet-award-short' => 'Bonus: :description', + 'wallet-credit-short' => 'Payment', + 'wallet-debit-short' => 'Deduction', + 'wallet-penalty-short' => 'Charge: :description', ]; diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss index 5c7423ab..57425037 100644 --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -1,248 +1,271 @@ // Fonts // Variables @import 'variables'; // Bootstrap @import '~bootstrap/scss/bootstrap'; @import 'menu'; @import 'toast'; @import 'forms'; html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } #error-page { position: absolute; top: 0; height: 100%; width: 100%; align-items: center; display: flex; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; - height: 8em; td { vertical-align: middle; + height: 8em; } tbody:not(:empty) + & { display: none; } } table { td.buttons, td.price, + td.datetime, td.selection { width: 1%; + white-space: nowrap; } + th.price, td.price { + width: 1%; text-align: right; + white-space: nowrap; } &.form-list { td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } } + + .list-details { + min-height: 1em; + + ul { + margin: 0; + padding-left: 1.2em; + } + } + + .btn-action { + line-height: 1; + padding: 0; + } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } #dashboard-nav { display: flex; flex-wrap: wrap; justify-content: center; & > a { padding: 1rem; text-align: center; white-space: nowrap; margin: 0.25rem; text-decoration: none; width: 150px; &.disabled { pointer-events: none; opacity: 0.6; } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } .plan-selector { .plan-ico { font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; margin-bottom: 1rem; border-radius: 50%; } ul { padding-left: 1.2em; &:last-child { margin-bottom: 0; } } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: .75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } // Bootstrap style fix .btn-link { border: 0; } + +.table thead th { + border: 0; +} diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index bd98655b..2ee3f308 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,261 +1,373 @@ diff --git a/src/routes/api.php b/src/routes/api.php index 68143fc2..949e10e0 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -1,109 +1,110 @@ 'api', 'prefix' => 'auth' ], function ($router) { Route::post('login', 'API\AuthController@login'); Route::group( ['middleware' => 'auth:api'], function ($router) { Route::get('info', 'API\AuthController@info'); Route::post('logout', 'API\AuthController@logout'); Route::post('refresh', 'API\AuthController@refresh'); } ); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'api', 'prefix' => 'auth' ], function ($router) { Route::post('password-reset/init', 'API\PasswordResetController@init'); Route::post('password-reset/verify', 'API\PasswordResetController@verify'); Route::post('password-reset', 'API\PasswordResetController@reset'); Route::get('signup/plans', 'API\SignupController@plans'); Route::post('signup/init', 'API\SignupController@init'); Route::post('signup/verify', 'API\SignupController@verify'); Route::post('signup', 'API\SignupController@signup'); } ); Route::group( [ 'domain' => \config('app.domain'), 'middleware' => 'auth:api', 'prefix' => 'v4' ], function () { Route::apiResource('domains', API\V4\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/status', 'API\V4\DomainsController@status'); Route::apiResource('entitlements', API\V4\EntitlementsController::class); Route::apiResource('packages', API\V4\PackagesController::class); Route::apiResource('skus', API\V4\SkusController::class); Route::apiResource('users', API\V4\UsersController::class); Route::get('users/{id}/status', 'API\V4\UsersController@status'); Route::apiResource('wallets', API\V4\WalletsController::class); + Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); Route::post('payments/mandate', 'API\V4\PaymentsController@mandateCreate'); Route::put('payments/mandate', 'API\V4\PaymentsController@mandateUpdate'); Route::delete('payments/mandate', 'API\V4\PaymentsController@mandateDelete'); } ); Route::group( [ 'domain' => \config('app.domain'), ], function () { Route::post('webhooks/payment/{provider}', 'API\V4\PaymentsController@webhook'); } ); Route::group( [ 'domain' => 'admin.' . \config('app.domain'), 'middleware' => ['auth:api', 'admin'], 'prefix' => 'v4', ], function () { Route::apiResource('domains', API\V4\Admin\DomainsController::class); Route::get('domains/{id}/confirm', 'API\V4\Admin\DomainsController@confirm'); Route::apiResource('entitlements', API\V4\Admin\EntitlementsController::class); Route::apiResource('packages', API\V4\Admin\PackagesController::class); Route::apiResource('skus', API\V4\Admin\SkusController::class); Route::apiResource('users', API\V4\Admin\UsersController::class); Route::post('users/{id}/suspend', 'API\V4\Admin\UsersController@suspend'); Route::post('users/{id}/unsuspend', 'API\V4\Admin\UsersController@unsuspend'); Route::apiResource('wallets', API\V4\Admin\WalletsController::class); Route::post('wallets/{id}/one-off', 'API\V4\Admin\WalletsController@oneOff'); Route::apiResource('discounts', API\V4\Admin\DiscountsController::class); } ); diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php index 8db9f01d..b9a56f6d 100644 --- a/src/tests/Browser/Pages/Wallet.php +++ b/src/tests/Browser/Pages/Wallet.php @@ -1,46 +1,48 @@ assertPathIs($this->url()) ->waitUntilMissing('@app .app-loader') ->assertSeeIn('#wallet .card-title', 'Account balance'); } /** * Get the element shortcuts for the page. * * @return array */ public function elements(): array { return [ '@app' => '#app', '@main' => '#wallet', '@payment-dialog' => '#payment-dialog', + '@nav' => 'ul.nav-tabs', + '@history-tab' => '#wallet-history', ]; } } diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php index 5ede6560..5584198b 100644 --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -1,76 +1,152 @@ deleteTestUser('wallets-controller@kolabnow.com'); + $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => -1234]); } /** * {@inheritDoc} */ public function tearDown(): void { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => 0]); + parent::tearDown(); } /** * Test wallet page (unauthenticated) */ public function testWalletUnauth(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit('/wallet')->on(new Home()); }); } /** * Test wallet "box" on Dashboard */ public function testDashboard(): void { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .name', 'Wallet') ->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF'); }); } /** * Test wallet page * * @depends testDashboard */ public function testWallet(): void { $this->browse(function (Browser $browser) { $browser->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('#wallet .card-title', 'Account balance') ->assertSeeIn('#wallet .card-text', 'Current account balance is -12,34 CHF'); }); } + + /** + * Test History tab + */ + public function testHistory(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); + + // Log out John and log in the test user + $this->browse(function (Browser $browser) { + $browser->visit('/logout') + ->waitForLocation('/login') + ->on(new Home()) + ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); + }); + + $package_kolab = \App\Package::where('title', 'kolab')->first(); + $user->assignPackage($package_kolab); + $wallet = $user->wallets()->first(); + + // Create some sample transactions + $transactions = $this->createTestTransactions($wallet); + $transactions = array_reverse($transactions); + $pages = array_chunk($transactions, 10 /* page size*/); + + $this->browse(function (Browser $browser) use ($pages, $wallet) { + $browser->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@nav #tab-history', 'History') + ->with('@history-tab', function (Browser $browser) use ($pages, $wallet) { + $browser->assertElementsCount('table tbody tr', 10) + ->assertSeeIn('#transactions-loader button', 'Load more'); + + foreach ($pages[0] as $idx => $transaction) { + $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')'; + $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger'; + $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()) + ->assertMissing("$selector td.selection button") + ->assertVisible("$selector td.price.{$priceStyle}"); + // TODO: Test more transaction details + } + + // Load the next page + $browser->click('#transactions-loader button') + ->waitUntilMissing('.app-loader') + ->assertElementsCount('table tbody tr', 12) + ->assertMissing('#transactions-loader button'); + + $debitEntry = null; + foreach ($pages[1] as $idx => $transaction) { + $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')'; + $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger'; + $browser->assertSeeIn("$selector td.description", $transaction->shortDescription()); + + if ($transaction->type == Transaction::WALLET_DEBIT) { + $debitEntry = $selector; + } else { + $browser->assertMissing("$selector td.selection button"); + } + } + + // Load sub-transactions + $browser->click("$debitEntry td.selection button") + ->waitUntilMissing('.app-loader') + ->assertElementsCount("$debitEntry td.description ul li", 2) + ->assertMissing("$debitEntry td.selection button"); + }); + }); + } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php new file mode 100644 index 00000000..6e718027 --- /dev/null +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -0,0 +1,150 @@ +deleteTestUser('wallets-controller@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('wallets-controller@kolabnow.com'); + + parent::tearDown(); + } + + /** + * 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@klab.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($transaction->shortDescription(), $json['list'][$idx]['description']); + $this->assertFalse($json['list'][$idx]['hasDetails']); + } + + $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'] + ); + + 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/TestCaseTrait.php b/src/tests/TestCaseTrait.php index 6c34878e..c8a6bc31 100644 --- a/src/tests/TestCaseTrait.php +++ b/src/tests/TestCaseTrait.php @@ -1,134 +1,203 @@ entitlements()->get() ->map(function ($ent) { return $ent->sku->title; }) ->toArray(); sort($skus); Assert::assertSame($expected, $skus); } /** * Creates the application. * * @return \Illuminate\Foundation\Application */ public function createApplication() { $app = require __DIR__ . '/../bootstrap/app.php'; $app->make(Kernel::class)->bootstrap(); return $app; } + /** + * Create a set of transaction log entries for a wallet + */ + protected function createTestTransactions($wallet) + { + $result = []; + $date = Carbon::now(); + $debit = 0; + $entitlementTransactions = []; + foreach ($wallet->entitlements as $entitlement) { + if ($entitlement->cost) { + $debit += $entitlement->cost; + $entitlementTransactions[] = $entitlement->createTransaction( + Transaction::ENTITLEMENT_BILLED, + $entitlement->cost + ); + } + } + + $transaction = Transaction::create([ + 'user_email' => null, + 'object_id' => $wallet->id, + 'object_type' => \App\Wallet::class, + 'type' => Transaction::WALLET_DEBIT, + 'amount' => $debit, + 'description' => 'Payment', + ]); + $result[] = $transaction; + + Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]); + + $transaction = Transaction::create([ + 'user_email' => null, + 'object_id' => $wallet->id, + 'object_type' => \App\Wallet::class, + 'type' => Transaction::WALLET_CREDIT, + 'amount' => 2000, + 'description' => 'Payment', + ]); + $transaction->created_at = $date->next(Carbon::MONDAY); + $transaction->save(); + $result[] = $transaction; + + $types = [ + Transaction::WALLET_AWARD, + Transaction::WALLET_PENALTY, + ]; + + // The page size is 10, so we generate so many to have at least two pages + $loops = 10; + while ($loops-- > 0) { + $transaction = Transaction::create([ + 'user_email' => 'jeroen.@jeroen.jeroen', + 'object_id' => $wallet->id, + 'object_type' => \App\Wallet::class, + 'type' => $types[count($result) % count($types)], + 'amount' => 11 * (count($result) + 1), + 'description' => 'TRANS' . $loops, + ]); + $transaction->created_at = $date->next(Carbon::MONDAY); + $transaction->save(); + $result[] = $transaction; + } + + return $result; + } + protected function deleteTestDomain($name) { Queue::fake(); $domain = Domain::withTrashed()->where('namespace', $name)->first(); if (!$domain) { return; } $job = new \App\Jobs\DomainDelete($domain->id); $job->handle(); $domain->forceDelete(); } protected function deleteTestUser($email) { Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return; } $job = new \App\Jobs\UserDelete($user->id); $job->handle(); $user->forceDelete(); } /** * Get Domain object by namespace, create it if needed. * Skip LDAP jobs. */ protected function getTestDomain($name, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); return Domain::firstOrCreate(['namespace' => $name], $attrib); } /** * Get User object by email, create it if needed. * Skip LDAP jobs. */ protected function getTestUser($email, $attrib = []) { // Disable jobs (i.e. skip LDAP oprations) Queue::fake(); $user = User::withTrashed()->where('email', $email)->first(); if (!$user) { return User::firstOrCreate(['email' => $email], $attrib); } if ($user->deleted_at) { $user->restore(); } return $user; } /** * Helper to access protected property of an object */ protected static function getObjectProperty($object, $property_name) { $reflection = new \ReflectionClass($object); $property = $reflection->getProperty($property_name); $property->setAccessible(true); return $property->getValue($object); } /** * Call protected/private method of a class. * * @param object $object Instantiated object that we will run method on. * @param string $methodName Method name to call * @param array $parameters Array of parameters to pass into method. * * @return mixed Method return. */ protected function invokeMethod($object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); $method = $reflection->getMethod($methodName); $method->setAccessible(true); return $method->invokeArgs($object, $parameters); } } diff --git a/src/tests/Unit/WalletTest.php b/src/tests/Unit/WalletTest.php new file mode 100644 index 00000000..67565751 --- /dev/null +++ b/src/tests/Unit/WalletTest.php @@ -0,0 +1,32 @@ + '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. + } +}