diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php --- a/src/app/Documents/Receipt.php +++ b/src/app/Documents/Receipt.php @@ -128,7 +128,7 @@ $company = $this->companyData(); if (self::$fakeMode) { - $country = 'CH'; + $vatRate = 7.7; $customer = [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, @@ -155,7 +155,7 @@ ]); } else { $customer = $this->customerData(); - $country = $this->wallet->owner->getSetting('country'); + $vatRate = $this->wallet->taxRate(); $items = $this->wallet->payments() ->where('status', PaymentProvider::STATUS_PAID) @@ -166,14 +166,6 @@ ->get(); } - $vatRate = \config('app.vat.rate'); - $vatCountries = explode(',', \config('app.vat.countries')); - $vatCountries = array_map('strtoupper', array_map('trim', $vatCountries)); - - if (!$country || !in_array(strtoupper($country), $vatCountries)) { - $vatRate = 0; - } - $totalVat = 0; $total = 0; $items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) { diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php --- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php @@ -80,23 +80,11 @@ } $amount = (int) ($request->amount * 100); - $type = $amount > 0 ? Transaction::WALLET_AWARD : Transaction::WALLET_PENALTY; + $method = $amount > 0 ? 'award' : 'penalty'; DB::beginTransaction(); - $wallet->balance += $amount; - $wallet->save(); - - Transaction::create( - [ - 'user_email' => \App\Utils::userEmailOrNull(), - 'object_id' => $wallet->id, - 'object_type' => Wallet::class, - 'type' => $type, - 'amount' => $amount, - 'description' => $request->description - ] - ); + $wallet->{$method}(abs($amount), $request->description); if ($user->role == 'reseller') { if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) { diff --git a/src/app/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php --- a/src/app/Observers/EntitlementObserver.php +++ b/src/app/Observers/EntitlementObserver.php @@ -106,87 +106,6 @@ */ public function deleting(Entitlement $entitlement) { - if ($entitlement->trashed()) { - return; - } - - // Start calculating the costs for the consumption of this entitlement if the - // existing consumption spans >= 14 days. - // - // Effect is that anything's free for the first 14 days - if ($entitlement->created_at >= Carbon::now()->subDays(14)) { - return; - } - - $owner = $entitlement->wallet->owner; - - if ($owner->isDegraded()) { - return; - } - - $now = Carbon::now(); - - // Determine if we're still within the trial period - $trial = $entitlement->wallet->trialInfo(); - if ( - !empty($trial) - && $entitlement->updated_at < $trial['end'] - && in_array($entitlement->sku_id, $trial['skus']) - ) { - if ($trial['end'] >= $now) { - return; - } - - $entitlement->updated_at = $trial['end']; - } - - // get the discount rate applied to the wallet. - $discount = $entitlement->wallet->getDiscountRate(); - - // just in case this had not been billed yet, ever - $diffInMonths = $entitlement->updated_at->diffInMonths($now); - $cost = (int) ($entitlement->cost * $discount * $diffInMonths); - $fee = (int) ($entitlement->fee * $diffInMonths); - - // this moves the hypothetical updated at forward to however many months past the original - $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); - - // now we have the diff in days since the last "billed" period end. - // This may be an entitlement paid up until February 28th, 2020, with today being March - // 12th 2020. Calculating the costs for the entitlement is based on the daily price - - // the price per day is based on the number of days in the last month - // or the current month if the period does not overlap with the previous month - // FIXME: This really should be simplified to $daysInMonth=30 - - $diffInDays = $updatedAt->diffInDays($now); - - if ($now->day >= $diffInDays) { - $daysInMonth = $now->daysInMonth; - } else { - $daysInMonth = \App\Utils::daysInLastMonth(); - } - - $pricePerDay = $entitlement->cost / $daysInMonth; - $feePerDay = $entitlement->fee / $daysInMonth; - - $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); - $fee += (int) (round($feePerDay * $diffInDays, 0)); - - $profit = $cost - $fee; - - if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) { - $desc = "Charged user {$owner->email}"; - $method = $profit > 0 ? 'credit' : 'debit'; - $wallet->{$method}(abs($profit), $desc); - } - - if ($cost == 0) { - return; - } - - // FIXME: Shouldn't we create per-entitlement transaction record? - - $entitlement->wallet->debit($cost); + $entitlement->wallet->chargeEntitlement($entitlement); } } diff --git a/src/app/Package.php b/src/app/Package.php --- a/src/app/Package.php +++ b/src/app/Package.php @@ -37,6 +37,7 @@ use HasTranslations; use UuidStrKeyTrait; + /** @var bool Indicates if the model should be timestamped. */ public $timestamps = false; /** @var array The attributes that are mass assignable */ diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -235,22 +235,9 @@ // TODO We should possibly be using the same exchange rate as for the original payment? $amount = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency); - $wallet->balance -= $amount; - $wallet->save(); + $method = $refund['type'] == self::TYPE_CHARGEBACK ? 'chargeback' : 'refund'; - if ($refund['type'] == self::TYPE_CHARGEBACK) { - $transaction_type = Transaction::WALLET_CHARGEBACK; - } else { - $transaction_type = Transaction::WALLET_REFUND; - } - - Transaction::create([ - 'object_id' => $wallet->id, - 'object_type' => Wallet::class, - 'type' => $transaction_type, - 'amount' => $amount * -1, - 'description' => $refund['description'] ?? '', - ]); + $wallet->{$method}($amount, $refund['description'] ?? ''); $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; diff --git a/src/app/TaxRate.php b/src/app/TaxRate.php new file mode 100644 --- /dev/null +++ b/src/app/TaxRate.php @@ -0,0 +1,32 @@ + The attributes that should be cast */ + protected $casts = [ + 'start' => 'datetime:Y-m-d H:i:s', + 'rate' => 'float' + ]; + + /** @var array The attributes that are mass assignable */ + protected $fillable = [ + 'country', + 'rate', + 'start', + ]; + + /** @var bool Indicates if the model should be timestamped. */ + public $timestamps = false; +} diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -66,6 +66,108 @@ } } + /** + * Add an award to this wallet's balance. + * + * @param int $amount The amount of award (in cents). + * @param string $description The transaction description + * + * @return Wallet Self + */ + public function award(int $amount, string $description = ''): Wallet + { + } + + /** + * Charge a specific entitlement (for use on entitlement delete). + * + * @param \App\Entitlement $entitlement The entitlement. + */ + public function chargeEntitlement(Entitlement $entitlement): void + { + // Sanity checks + if ($entitlement->trashed() || $entitlement_wallet_id != $this->id || !$this->owner) { + return; + } + + // Start calculating the costs for the consumption of this entitlement if the + // existing consumption spans >= 14 days. + // + // Effect is that anything's free for the first 14 days + if ($entitlement->created_at >= Carbon::now()->subDays(14)) { + return; + } + + if ($this->owner->isDegraded()) { + return; + } + + $now = Carbon::now(); + + // Determine if we're still within the trial period + $trial = $this->wallet->trialInfo(); + if ( + !empty($trial) + && $entitlement->updated_at < $trial['end'] + && in_array($entitlement->sku_id, $trial['skus']) + ) { + if ($trial['end'] >= $now) { + return; + } + + $entitlement->updated_at = $trial['end']; + } + + // get the discount rate applied to the wallet. + $discount = $this->wallet->getDiscountRate(); + + // just in case this had not been billed yet, ever + $diffInMonths = $entitlement->updated_at->diffInMonths($now); + $cost = (int) ($entitlement->cost * $discount * $diffInMonths); + $fee = (int) ($entitlement->fee * $diffInMonths); + + // this moves the hypothetical updated at forward to however many months past the original + $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths); + + // now we have the diff in days since the last "billed" period end. + // This may be an entitlement paid up until February 28th, 2020, with today being March + // 12th 2020. Calculating the costs for the entitlement is based on the daily price + + // the price per day is based on the number of days in the last month + // or the current month if the period does not overlap with the previous month + // FIXME: This really should be simplified to $daysInMonth=30 + + $diffInDays = $updatedAt->diffInDays($now); + + if ($now->day >= $diffInDays) { + $daysInMonth = $now->daysInMonth; + } else { + $daysInMonth = \App\Utils::daysInLastMonth(); + } + + $pricePerDay = $entitlement->cost / $daysInMonth; + $feePerDay = $entitlement->fee / $daysInMonth; + + $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0)); + $fee += (int) (round($feePerDay * $diffInDays, 0)); + + $profit = $cost - $fee; + + if ($profit != 0 && $owner->tenant && ($wallet = $this->owner->tenant->wallet())) { + $desc = "Charged user {$owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } + + if ($cost == 0) { + return; + } + + // TODO: Create per-entitlement transaction record? + + $this->wallet->debit($cost); + } + /** * Charge entitlements in the wallet * @@ -194,6 +296,7 @@ }) ->all(); + $max = 12 * 25; while ($max > 0) { foreach ($entitlements as &$entitlement) { @@ -229,6 +332,20 @@ return $until; } + /** + * Chargeback an amount of pecunia from this wallet's balance. + * + * @param int $amount The amount of pecunia to charge back (in cents). + * @param string $description The transaction description + * + * @return Wallet Self + */ + public function chargeback(int $amount, string $description = ''): Wallet + { + $this->balanceUpdate(Transaction::WALLET_CHARGEBACK, $amount, $description); + return $this; + } + /** * Controllers of this wallet. * @@ -254,20 +371,7 @@ */ public function credit(int $amount, string $description = ''): Wallet { - $this->balance += $amount; - - $this->save(); - - Transaction::create( - [ - 'object_id' => $this->id, - 'object_type' => Wallet::class, - 'type' => Transaction::WALLET_CREDIT, - 'amount' => $amount, - 'description' => $description - ] - ); - + $this->balanceUpdate(Transaction::WALLET_CREDIT, $amount, $description); return $this; } @@ -282,28 +386,7 @@ */ public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet { - if ($amount == 0) { - return $this; - } - - $this->balance -= $amount; - - $this->save(); - - $transaction = Transaction::create( - [ - 'object_id' => $this->id, - 'object_type' => Wallet::class, - 'type' => Transaction::WALLET_DEBIT, - 'amount' => $amount * -1, - 'description' => $description - ] - ); - - if (!empty($eTIDs)) { - Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); - } - + $this->balanceUpdate(Transaction::WALLET_DEBIT, $amount, $description, $eTIDs); return $this; } @@ -410,6 +493,20 @@ return $this->hasMany(Payment::class); } + /** + * Add a penalty to this wallet's balance. + * + * @param int $amount The amount of penalty (in cents). + * @param string $description The transaction description + * + * @return Wallet Self + */ + public function penalty(int $amount, string $description = ''): Wallet + { + $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description); + return $this; + } + /** * Plan of the wallet. * @@ -436,6 +533,58 @@ } } + /** + * Refund an amount of pecunia from this wallet's balance. + * + * @param int $amount The amount of pecunia to refund (in cents). + * @param string $description The transaction description + * + * @return Wallet Self + */ + public function refund(int $amount, string $description = ''): Wallet + { + $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description); + return $this; + } + + /** + * Get the tax rate for the wallet owner country. + * + * @param \DateTime $date Get the rate valid for the specified date-time, + * without it the current rate will be returned (if any specified). + * + * @return float Tax rate value + */ + public function taxRate($start = null): float + { + if (!$this->owner) { + return 0.0; + } + + $country = $this->owner->getSetting('country'); + + if (!$country) { + return 0.0; + } + + // Use the old options if specified for now (to be removed) + $vatRate = \config('app.vat.rate'); + $vatCountries = explode(',', \config('app.vat.countries')); + + if (!empty($vatCountries)) { + $vatCountries = array_map('strtoupper', array_map('trim', $vatCountries)); + return in_array(strtoupper($country), $vatCountries) ? $vatRate : 0.0; + } + + // Get the tax rate from the database + $rate = TaxRate::where('country', $country)->where('start', '<=', $start) + ->orderByDesc('start') + ->limit(1) + ->first(); + + return $rate ? $rate->rate : 0.0; + } + /** * Retrieve the transactions against this wallet. * @@ -556,4 +705,36 @@ return $charges; } + + /** + * Update the wallet balance, and create a transaction record + */ + protected function balanceUpdate(string $type, int $amount, $description = null, array $eTIDs = []) + { + if ($amount === 0) { + return $this; + } + + if (in_array($type, [Transaction::WALLET_DEBIT])) { + $amount -= 1; + } + + $this->balance += $amount; + $this->save(); + + $transaction = Transaction::create([ + 'user_email' => \App\Utils::userEmailOrNull(), + 'object_id' => $this->id, + 'object_type' => Wallet::class, + 'type' => $type, + 'amount' => $amount, + 'description' => $description, + ]); + + if (!empty($eTIDs)) { + Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); + } + + return $this; + } } diff --git a/src/database/migrations/2023_02_17_100000_tax_rates_table.php b/src/database/migrations/2023_02_17_100000_tax_rates_table.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2023_02_17_100000_tax_rates_table.php @@ -0,0 +1,35 @@ +bigIncrements('id'); + $table->string('country', 2); + $table->timestamp('start')->useCurrent(); + $table->float('rate', 5, 2); + + $table->unique(['country', 'start']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('tax_rates'); + } +}; diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -557,6 +557,38 @@ */ } + /** + * Tests for award() and penalty() + */ + public function testAwardAndPenalty(): void + { + $this->markTestIncomplete(); + } + + /** + * Tests for chargeback() and refund() + */ + public function testChargebackAndRefund(): void + { + $this->markTestIncomplete(); + } + + /** + * Tests for chargeEntitlement() + */ + public function testChargeEntitlement(): void + { + $this->markTestIncomplete(); + } + + /** + * Tests for taxRate() + */ + public function testtaxRate(): void + { + $this->markTestIncomplete(); + } + /** * Tests for updateEntitlements() */