diff --git a/src/app/Console/Commands/WalletTransactions.php b/src/app/Console/Commands/WalletTransactions.php index c6e2736a..3be8872d 100644 --- a/src/app/Console/Commands/WalletTransactions.php +++ b/src/app/Console/Commands/WalletTransactions.php @@ -1,73 +1,72 @@ argument('wallet'))->first(); if (!$wallet) { return 1; } 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", + " + %s: %s", $element->id, - $element->created_at, $element->toString() ) ); } } } } } diff --git a/src/app/Entitlement.php b/src/app/Entitlement.php index 57273151..3ab2834b 100644 --- a/src/app/Entitlement.php +++ b/src/app/Entitlement.php @@ -1,136 +1,151 @@ 'integer', ]; /** * Return the costs per day for this entitlement. * * @return float */ public function costsPerDay() { if ($this->cost == 0) { return (float) 0; } $discount = $this->wallet->getDiscountRate(); $daysInLastMonth = \App\Utils::daysInLastMonth(); $costsPerDay = (float) ($this->cost * $discount) / $daysInLastMonth; return $costsPerDay; } /** * Create a transaction record for this entitlement. * * @param string $type The type of transaction ('created', 'billed', 'deleted'), but use the * \App\Transaction constants. * @param int $amount The amount involved in cents * * @return string The transaction ID */ public function createTransaction($type, $amount = null) { $transaction = \App\Transaction::create( [ - 'user_email' => \App\Utils::userEmailOrNull(), 'object_id' => $this->id, 'object_type' => \App\Entitlement::class, 'type' => $type, 'amount' => $amount ] ); return $transaction->id; } /** * Principally entitleable objects such as 'Domain' or 'User'. * * @return mixed */ public function entitleable() { return $this->morphTo(); } + /** + * Returns entitleable object title (e.g. email or domain name). + * + * @return string|null An object title/name + */ + public function entitleableTitle(): ?string + { + if ($this->entitleable instanceof \App\User) { + return $this->entitleable->email; + } + + if ($this->entitleable instanceof \App\Domain) { + return $this->entitleable->namespace; + } + } + /** * The SKU concerned. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function sku() { return $this->belongsTo('App\Sku'); } /** * The wallet this entitlement is being billed to * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function wallet() { return $this->belongsTo('App\Wallet'); } /** * Cost mutator. Make sure cost is integer. */ public function setCostAttribute($cost): void { $this->attributes['cost'] = round($cost); } } diff --git a/src/app/Observers/TransactionObserver.php b/src/app/Observers/TransactionObserver.php index 86121e5f..d24cc474 100644 --- a/src/app/Observers/TransactionObserver.php +++ b/src/app/Observers/TransactionObserver.php @@ -1,26 +1,30 @@ {$transaction->getKeyName()} = $allegedly_unique; break; } } + + if (!isset($transaction->user_email)) { + $transaction->user_email = \App\Utils::userEmailOrNull(); + } } } diff --git a/src/app/Transaction.php b/src/app/Transaction.php index 4ebf1820..d504e468 100644 --- a/src/app/Transaction.php +++ b/src/app/Transaction.php @@ -1,226 +1,191 @@ '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() + /** + * Returns the entitlement to which the transaction is assigned (if any) + * + * @return \App\Entitlement|null The entitlement + */ + public function entitlement(): ?Entitlement { - if ($this->object_type !== \App\Entitlement::class) { + if ($this->object_type !== Entitlement::class) { return null; } - return \App\Entitlement::withTrashed()->where('id', $this->object_id)->first(); + return Entitlement::withTrashed()->find($this->object_id); } - public function setTypeAttribute($value) + /** + * Transaction type mutator + * + * @throws \Exception + */ + public function setTypeAttribute($value): void { 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. + * Returns a short text describing the transaction. * - * @return int|null + * @return string The description */ - private function getEntitlementCost(): ?int + public function shortDescription(): string { - 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(); + $label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short'; - return $cost * $discount; + return \trans("transactions.{$label}", $this->descriptionParams()); } /** - * Return the object email if any. This is the email for the target user entitlement. + * Returns a text describing the transaction. * - * @return string|null + * @return string The description */ - private function getEntitlementObjectEmail(): ?string + public function toString(): string { - $entitlement = $this->entitlement(); - - if (!$entitlement) { - return null; - } - - $user = \App\User::withTrashed()->where('id', $entitlement->object_id)->first(); - - if (!$user) { - \Log::debug("No entitleable for {$entitlement->id} ?"); - return null; - } + $label = $this->objectTypeToLabelString() . '-' . $this->{'type'}; - return $user->email; + return \trans("transactions.{$label}", $this->descriptionParams()); } /** - * Return the title for the SKU this entitlement is for. + * Returns a wallet to which the transaction is assigned (if any) * - * @return string|null + * @return \App\Wallet|null The wallet */ - private function getEntitlementSkuTitle(): ?string + public function wallet(): ?Wallet { - if (!$this->entitlement()) { + if ($this->object_type !== Wallet::class) { return null; } - return $this->entitlement()->sku->{'title'}; + return Wallet::find($this->object_id); } /** - * Return the description for the wallet, if any, or 'default wallet'. + * Collect transaction parameters used in (localized) descriptions * - * @return string + * @return array Parameters */ - public function getWalletDescription() + private function descriptionParams(): array { - $description = null; + $result = [ + 'user_email' => $this->user_email, + 'description' => $this->{'description'}, + ]; if ($entitlement = $this->entitlement()) { - $description = $entitlement->wallet->{'description'}; + $wallet = $entitlement->wallet; + $cost = $entitlement->cost; + $discount = $entitlement->wallet->getDiscountRate(); + + $result['entitlement_cost'] = $cost * $discount; + $result['object'] = $entitlement->entitleableTitle(); + $result['sku_title'] = $entitlement->sku->{'title'}; + } else { + $wallet = $this->wallet(); } - if ($wallet = $this->wallet()) { - $description = $wallet->{'description'}; - } + $result['wallet'] = $wallet->{'description'} ?: 'Default wallet'; + $result['amount'] = $wallet->money($this->amount); - return $description ?: 'Default wallet'; + return $result; } /** * 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) { + if ($this->object_type == Entitlement::class) { return 'entitlement'; } - if ($this->object_type == \App\Wallet::class) { + if ($this->object_type == Wallet::class) { return 'wallet'; } return null; } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index 916ef790..cc9cf2d0 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,369 +1,367 @@ 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; } /** * Calculate for how long the current balance will last. * * @return \Carbon\Carbon Date */ public function balanceLastsUntil() { $balance = $this->balance; // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); // the number of days this balance, minus the expected charges, would last $daysDelta = ($balance - $expectedCharge) / $costsPerDay; // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); return $entitlement->updated_at->copy()->addDays($daysDelta); } /** * 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); } /** * 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( [ - '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 \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/resources/lang/en/transactions.php b/src/resources/lang/en/transactions.php index d83320bb..d0248a51 100644 --- a/src/resources/lang/en/transactions.php +++ b/src/resources/lang/en/transactions.php @@ -1,21 +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', + 'entitlement-created' => ':user_email created :sku_title for :object', + 'entitlement-billed' => ':sku_title for :object is billed at :amount', + 'entitlement-deleted' => ':user_email deleted :sku_title for :object', - '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-award' => 'Bonus of :amount awarded to :wallet; :description', + 'wallet-credit' => ':amount was added to the balance of :wallet', + 'wallet-debit' => ':amount was deducted from the balance of :wallet', + 'wallet-penalty' => 'The balance of :wallet 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', + 'entitlement-created-short' => 'Added :sku_title for :object', + 'entitlement-billed-short' => 'Billed :sku_title for :object', + 'entitlement-deleted-short' => 'Deleted :sku_title for :object', 'wallet-award-short' => 'Bonus: :description', 'wallet-credit-short' => 'Payment', 'wallet-debit-short' => 'Deduction', 'wallet-penalty-short' => 'Charge: :description', ]; diff --git a/src/tests/Unit/TransactionTest.php b/src/tests/Unit/TransactionTest.php index 6ee2f9b1..15c9976b 100644 --- a/src/tests/Unit/TransactionTest.php +++ b/src/tests/Unit/TransactionTest.php @@ -1,83 +1,205 @@ delete(); + $user = $this->getTestUser('jane@kolabnow.com'); + $wallet = $user->wallets()->first(); - public function tearDown(): void - { - parent::tearDown(); - } + // Create transactions - public function testLabel() - { - $transactions = Transaction::limit(20)->get(); + $transaction = Transaction::create([ + 'object_id' => $wallet->id, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_PENALTY, + 'amount' => 9, + 'description' => "A test penalty" + ]); - foreach ($transactions as $transaction) { - $this->assertNotNull($transaction->toString()); - } - } + $transaction = Transaction::create([ + 'object_id' => $wallet->id, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_DEBIT, + 'amount' => 10 + ]); - public function testWalletPenalty() - { - $user = $this->getTestUser('jane@kolabnow.com'); - $wallet = $user->wallets()->first(); + $transaction = Transaction::create([ + 'object_id' => $wallet->id, + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_CREDIT, + 'amount' => 11 + ]); - $transaction = Transaction::create( - [ + $transaction = Transaction::create([ 'object_id' => $wallet->id, - 'object_type' => \App\Wallet::class, - 'type' => Transaction::WALLET_PENALTY, - 'amount' => 9 - ] + 'object_type' => Wallet::class, + 'type' => Transaction::WALLET_AWARD, + 'amount' => 12, + 'description' => "A test award" + ]); + + $sku = Sku::where('title', 'mailbox')->first(); + $entitlement = Entitlement::where('sku_id', $sku->id)->first(); + $transaction = Transaction::create([ + 'user_email' => 'test@test.com', + 'object_id' => $entitlement->id, + 'object_type' => Entitlement::class, + 'type' => Transaction::ENTITLEMENT_CREATED, + 'amount' => 13 + ]); + + $sku = Sku::where('title', 'domain-hosting')->first(); + $entitlement = Entitlement::where('sku_id', $sku->id)->first(); + $transaction = Transaction::create([ + 'user_email' => 'test@test.com', + 'object_id' => $entitlement->id, + 'object_type' => Entitlement::class, + 'type' => Transaction::ENTITLEMENT_BILLED, + 'amount' => 14 + ]); + + $sku = Sku::where('title', 'storage')->first(); + $entitlement = Entitlement::where('sku_id', $sku->id)->first(); + $transaction = Transaction::create([ + 'user_email' => 'test@test.com', + 'object_id' => $entitlement->id, + 'object_type' => Entitlement::class, + 'type' => Transaction::ENTITLEMENT_DELETED, + 'amount' => 15 + ]); + + $transactions = Transaction::where('amount', '<', 20)->orderBy('amount')->get(); + + $this->assertSame(9, $transactions[0]->amount); + $this->assertSame(Transaction::WALLET_PENALTY, $transactions[0]->type); + $this->assertSame( + "The balance of Default wallet was reduced by 0,09 CHF; A test penalty", + $transactions[0]->toString() + ); + $this->assertSame( + "Charge: A test penalty", + $transactions[0]->shortDescription() ); - $this->assertEquals($transaction->{'type'}, Transaction::WALLET_PENALTY); + $this->assertSame(10, $transactions[1]->amount); + $this->assertSame(Transaction::WALLET_DEBIT, $transactions[1]->type); + $this->assertSame( + "0,10 CHF was deducted from the balance of Default wallet", + $transactions[1]->toString() + ); + $this->assertSame( + "Deduction", + $transactions[1]->shortDescription() + ); + + $this->assertSame(11, $transactions[2]->amount); + $this->assertSame(Transaction::WALLET_CREDIT, $transactions[2]->type); + $this->assertSame( + "0,11 CHF was added to the balance of Default wallet", + $transactions[2]->toString() + ); + $this->assertSame( + "Payment", + $transactions[2]->shortDescription() + ); + + $this->assertSame(12, $transactions[3]->amount); + $this->assertSame(Transaction::WALLET_AWARD, $transactions[3]->type); + $this->assertSame( + "Bonus of 0,12 CHF awarded to Default wallet; A test award", + $transactions[3]->toString() + ); + $this->assertSame( + "Bonus: A test award", + $transactions[3]->shortDescription() + ); + + $ent = $transactions[4]->entitlement(); + $this->assertSame(13, $transactions[4]->amount); + $this->assertSame(Transaction::ENTITLEMENT_CREATED, $transactions[4]->type); + $this->assertSame( + "test@test.com created mailbox for " . $ent->entitleableTitle(), + $transactions[4]->toString() + ); + $this->assertSame( + "Added mailbox for " . $ent->entitleableTitle(), + $transactions[4]->shortDescription() + ); + + $ent = $transactions[5]->entitlement(); + $this->assertSame(14, $transactions[5]->amount); + $this->assertSame(Transaction::ENTITLEMENT_BILLED, $transactions[5]->type); + $this->assertSame( + sprintf("%s for %s is billed at 0,14 CHF", $ent->sku->title, $ent->entitleableTitle()), + $transactions[5]->toString() + ); + $this->assertSame( + sprintf("Billed %s for %s", $ent->sku->title, $ent->entitleableTitle()), + $transactions[5]->shortDescription() + ); + + $ent = $transactions[6]->entitlement(); + $this->assertSame(15, $transactions[6]->amount); + $this->assertSame(Transaction::ENTITLEMENT_DELETED, $transactions[6]->type); + $this->assertSame( + sprintf("test@test.com deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()), + $transactions[6]->toString() + ); + $this->assertSame( + sprintf("Deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()), + $transactions[6]->shortDescription() + ); } - public function testInvalidType() + /** + * Test that an exception is being thrown on invalid type + */ + public function testInvalidType(): void { - $user = $this->getTestUser('jane@kolabnow.com'); - $wallet = $user->wallets()->first(); - $this->expectException(\Exception::class); $transaction = Transaction::create( [ - 'object_id' => $wallet->id, - 'object_type' => \App\Wallet::class, + 'object_id' => 'fake-id', + 'object_type' => Wallet::class, 'type' => 'invalid', 'amount' => 9 ] ); } public function testEntitlementForWallet(): void { $transaction = \App\Transaction::where('object_type', \App\Wallet::class) ->whereIn('object_id', \App\Wallet::pluck('id'))->first(); $entitlement = $transaction->entitlement(); $this->assertNull($entitlement); $this->assertNotNull($transaction->wallet()); } public function testWalletForEntitlement(): void { $transaction = \App\Transaction::where('object_type', \App\Entitlement::class) ->whereIn('object_id', \App\Entitlement::pluck('id'))->first(); $wallet = $transaction->wallet(); $this->assertNull($wallet); $this->assertNotNull($transaction->entitlement()); } }