diff --git a/src/app/Console/Commands/Data/Import/TaxRatesCommand.php b/src/app/Console/Commands/Data/Import/TaxRatesCommand.php new file mode 100644 --- /dev/null +++ b/src/app/Console/Commands/Data/Import/TaxRatesCommand.php @@ -0,0 +1,75 @@ +argument('file'); + $date = $this->argument('date'); + + if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/', $date)) { + $this->error("Invalid start date"); + return 1; + } + + if (!file_exists($file)) { + $this->error("Invalid file location"); + return 1; + } + + $rates = json_decode(file_get_contents($file), true); + + if (!is_array($rates) || empty($rates)) { + $this->error("Invalid or empty input data format"); + return 1; + } + + $date .= ' 00:00:00'; + + foreach ($rates as $country => $rate) { + if (is_string($country) && strlen($country) && (is_float($rate) || is_int($rate))) { + $existing = TaxRate::where('country', $country) + ->where('start', '<=', $date) + ->limit(1) + ->first(); + + if (!$existing || $existing->rate != $rate) { + TaxRate::create([ + 'start' => $date, + 'rate' => $rate, + 'country' => strtoupper($country), + ]); + + $this->info("Added {$country}:{$rate}"); + continue; + } + } + + $this->info("Skipped {$country}:{$rate}"); + } + } +} 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,6 @@ $company = $this->companyData(); if (self::$fakeMode) { - $country = 'CH'; $customer = [ 'id' => $this->wallet->owner->id, 'wallet_id' => $this->wallet->id, @@ -153,10 +152,14 @@ 'updated_at' => $start->copy()->next()->next()->next(), ], ]); + + $items = $items->map(function ($payment) { + $payment->tax_rate = 7.7; + $payment->base_amount = $payment->amount + round($payment->amount * $payment->tax_rate / 100); + return $payment; + }); } else { $customer = $this->customerData(); - $country = $this->wallet->owner->getSetting('country'); - $items = $this->wallet->payments() ->where('status', PaymentProvider::STATUS_PAID) ->where('updated_at', '>=', $start) @@ -166,22 +169,18 @@ ->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; - } - + $vatRate = 0; $totalVat = 0; - $total = 0; - $items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) { + $total = 0; // excluding total VAT + + $items = $items->map(function ($item) use (&$total, &$totalVat, &$vatRate, $appName) { $amount = $item->amount; - if ($vatRate > 0) { - $amount = round($amount * ((100 - $vatRate) / 100)); - $totalVat += $item->amount - $amount; + if ($item->tax_rate > 0) { + $vat = round($item->base_amount * $item->tax_rate / 100); + $amount -= $vat; + $totalVat += $vat; + $vatRate = $item->tax_rate; // TODO: Multiple rates } $total += $amount; 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,29 +80,17 @@ } $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())) { $desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}"; - $method = $amount > 0 ? 'debit' : 'credit'; - $tenant_wallet->{$method}(abs($amount), $desc); + $tenant_method = $amount > 0 ? 'debit' : 'credit'; + $tenant_wallet->{$tenant_method}(abs($amount), $desc); } } @@ -110,7 +98,7 @@ $response = [ 'status' => 'success', - 'message' => \trans("app.wallet-{$type}-success"), + 'message' => \trans("app.wallet-{$method}-success"), 'balance' => $wallet->balance ]; diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -227,6 +227,8 @@ 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; + self::addTax($wallet, $request); + $provider = PaymentProvider::factory($wallet, $currency); $result = $provider->payment($wallet, $request); @@ -340,6 +342,8 @@ 'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment', ]; + self::addTax($wallet, $request); + $result = $provider->payment($wallet, $request); return !empty($result); @@ -484,4 +488,32 @@ 'page' => $page, ]); } + + /** + * Calculates tax for the payment, fills the request with additional properties + */ + protected static function addTax(Wallet $wallet, array &$request): void + { + $request['tax_rate'] = 0.0; + $request['base_amount'] = $request['amount']; + + $rate = $wallet->taxRate(); + + if ($rate > 0) { + $request['tax_rate'] = $rate; + + switch (\config('app.vat.mode')) { + case 1: + // In this mode tax is added on top of the payment. The amount + // to pay grows, but we keep wallet balance without tax. + $request['amount'] = $request['amount'] + round($request['amount'] * $rate / 100); + break; + + default: + // In this mode tax is "swallowed" by the vendor. The payment + // amount does not change + break; + } + } + } } 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/Payment.php b/src/app/Payment.php --- a/src/app/Payment.php +++ b/src/app/Payment.php @@ -7,9 +7,11 @@ /** * A payment operation on a wallet. * - * @property int $amount Amount of money in cents of system currency + * @property int $amount Amount of money in cents of system currency (payment provider) + * @property int $base_amount Amount of money in cents of system currency (wallet balance) * @property string $description Payment description * @property string $id Mollie's Payment ID + * @property float $tax_rate Tax rate * @property \App\Wallet $wallet The wallet * @property string $wallet_id The ID of the wallet * @property string $currency Currency of this payment @@ -22,7 +24,9 @@ /** @var array The attributes that should be cast */ protected $casts = [ - 'amount' => 'integer' + 'amount' => 'integer', + 'base_amount' => 'integer', + 'tax_rate' => 'float', ]; /** @var array The attributes that are mass assignable */ @@ -30,9 +34,11 @@ 'id', 'wallet_id', 'amount', + 'base_amount', 'description', 'provider', 'status', + 'tax_rate', 'type', 'currency', 'currency_amount', diff --git a/src/app/Providers/Payment/Coinbase.php b/src/app/Providers/Payment/Coinbase.php --- a/src/app/Providers/Payment/Coinbase.php +++ b/src/app/Providers/Payment/Coinbase.php @@ -352,7 +352,7 @@ $description = 'Payment'; $description .= " transaction {$payment->id} using Coinbase"; - $payment->wallet->credit($payment->amount, $description); + $payment->wallet->credit($payment->base_amount, $description); } /** diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -517,7 +517,7 @@ $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; - $payment->wallet->credit($payment->amount, $description); + $payment->wallet->credit($payment->base_amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -454,7 +454,7 @@ $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; $description .= " transaction {$payment->id} using {$method}"; - $payment->wallet->credit($payment->amount, $description); + $payment->wallet->credit($payment->base_amount, $description); // Unlock the disabled auto-payment mandate if ($payment->wallet->balance >= 0) { 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 @@ -101,6 +101,8 @@ * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents + * - base_amount: Balance'able base amount in cents + * - tax_rate: Tax rate * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method @@ -156,6 +158,8 @@ * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents + * - base_amount: Balance'able base amount in cents + * - tax_rate: Tax rate * - currency: The operation currency * - type: first/oneoff/recurring * - description: Operation description @@ -189,6 +193,8 @@ $db_payment->description = $payment['description'] ?? ''; $db_payment->status = $payment['status'] ?? self::STATUS_OPEN; $db_payment->amount = $payment['amount'] ?? 0; + $db_payment->base_amount = $payment['base_amount'] ?? ($payment['amount'] ?? 0); + $db_payment->tax_rate = $payment['tax_rate'] ?? 0; $db_payment->type = $payment['type']; $db_payment->wallet_id = $wallet_id; $db_payment->provider = $this->name(); @@ -235,22 +241,11 @@ // 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(); + // TODO: Set tax_rate and base_amount - if ($refund['type'] == self::TYPE_CHARGEBACK) { - $transaction_type = Transaction::WALLET_CHARGEBACK; - } else { - $transaction_type = Transaction::WALLET_REFUND; - } + $method = $refund['type'] == self::TYPE_CHARGEBACK ? 'chargeback' : '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,35 @@ + 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,109 @@ } } + /** + * 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 + { + return $this->balanceUpdate(Transaction::WALLET_AWARD, $amount, $description); + } + + /** + * 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->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->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 && $this->owner->tenant && ($wallet = $this->owner->tenant->wallet())) { + $desc = "Charged user {$this->owner->email}"; + $method = $profit > 0 ? 'credit' : 'debit'; + $wallet->{$method}(abs($profit), $desc); + } + + if ($cost == 0) { + return; + } + + // TODO: Create per-entitlement transaction record? + + $this->debit($cost); + } + /** * Charge entitlements in the wallet * @@ -229,6 +332,19 @@ 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 + { + return $this->balanceUpdate(Transaction::WALLET_CHARGEBACK, $amount, $description); + } + /** * Controllers of this wallet. * @@ -254,21 +370,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 - ] - ); - - return $this; + return $this->balanceUpdate(Transaction::WALLET_CREDIT, $amount, $description); } /** @@ -282,29 +384,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]); - } - - return $this; + return $this->balanceUpdate(Transaction::WALLET_DEBIT, $amount, $description, $eTIDs); } /** @@ -410,6 +490,19 @@ 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 + { + return $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description); + } + /** * Plan of the wallet. * @@ -436,6 +529,51 @@ } } + /** + * 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 + { + return $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description); + } + + /** + * Get the tax rate for the wallet owner country. + * + * @param ?\DateTime $start Get the rate valid for the specified date-time, + * without it the current rate will be returned (if exists). + * + * @return float Tax rate value + */ + public function taxRate(\DateTime $start = null): float + { + $owner = $this->owner; + + // Make it working with deleted accounts too + if (!$owner) { + $owner = $this->owner()->withTrashed()->first(); + } + + $country = $owner->getSetting('country'); + + if (!$country) { + return 0.0; + } + + $rate = TaxRate::where('country', $country) + ->where('start', '<=', ($start ?: now())->format('Y-m-d h:i:s')) + ->orderByDesc('start') + ->limit(1) + ->first(); + + return $rate ? $rate->rate : 0.0; + } + /** * Retrieve the transactions against this wallet. * @@ -556,4 +694,38 @@ 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_CREDIT, Transaction::WALLET_AWARD])) { + $amount = abs($amount); + } else { + $amount = abs($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/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -247,8 +247,7 @@ ], 'vat' => [ - 'countries' => env('VAT_COUNTRIES'), - 'rate' => (float) env('VAT_RATE'), + 'mode' => (int) env('VAT_MODE', 0), ], 'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255', 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,88 @@ +string('id', 36)->primary(); + $table->string('country', 2); + $table->timestamp('start')->useCurrent(); + $table->double('rate', 5, 2); + + $table->unique(['country', 'start']); + }); + + Schema::table( + 'payments', + function (Blueprint $table) { + // FIXME: It could be a foreign key instead, but I don't see a good reason for that + $table->double('tax_rate', 5, 2)->default(0); + // FIXME: A better name than base_amount? + $table->integer('base_amount')->nullable(); // temporarily allow null + } + ); + + DB::table('payments')->update(['base_amount' => DB::raw("`amount`")]); + + Schema::table( + 'payments', + function (Blueprint $table) { + $table->integer('base_amount')->nullable(false)->change(); // remove nullable + } + ); + + // Migrate old tax rates (and existing payments) + if (($countries = \env('VAT_COUNTRIES')) && ($rate = \env('VAT_RATE'))) { + $countries = explode(',', strtoupper(trim($countries))); + + foreach ($countries as $country) { + \App\TaxRate::create([ + 'start' => new DateTime('2015-01-01 00:00:00'), + 'rate' => $rate, + 'country' => $country, + ]); + } + + DB::table('payments')->whereIn('wallet_id', function ($query) use ($countries) { + $query->select('id') + ->from('wallets') + ->whereIn('user_id', function ($query) use ($countries) { + $query->select('user_id') + ->from('user_settings') + ->where('key', 'country') + ->whereIn('value', $countries); + }); + }) + ->update(['tax_rate' => $rate]); + } + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('tax_rates'); + + Schema::table( + 'payments', + function (Blueprint $table) { + $table->dropColumn('tax_rate'); + $table->dropColumn('base_amount'); + } + ); + } +}; diff --git a/src/tests/Browser/Reseller/WalletTest.php b/src/tests/Browser/Reseller/WalletTest.php --- a/src/tests/Browser/Reseller/WalletTest.php +++ b/src/tests/Browser/Reseller/WalletTest.php @@ -138,6 +138,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]); @@ -154,6 +155,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]); diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -131,6 +131,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]); @@ -147,6 +148,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]); diff --git a/src/tests/Feature/Console/Data/Stats/CollectorTest.php b/src/tests/Feature/Console/Data/Stats/CollectorTest.php --- a/src/tests/Feature/Console/Data/Stats/CollectorTest.php +++ b/src/tests/Feature/Console/Data/Stats/CollectorTest.php @@ -53,6 +53,7 @@ 'description' => '', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 1000, + 'base_amount' => 1000, 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', diff --git a/src/tests/Feature/Controller/Admin/StatsTest.php b/src/tests/Feature/Controller/Admin/StatsTest.php --- a/src/tests/Feature/Controller/Admin/StatsTest.php +++ b/src/tests/Feature/Controller/Admin/StatsTest.php @@ -133,7 +133,8 @@ 'id' => 'test1', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, - 'amount' => 1000, // EUR + 'amount' => 1000, + 'base_amount' => 1000, 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $wallet->id, 'provider' => 'mollie', @@ -144,7 +145,8 @@ 'id' => 'test2', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, - 'amount' => 2000, // EUR + 'amount' => 2000, + 'base_amount' => 2000, 'type' => PaymentProvider::TYPE_RECURRING, 'wallet_id' => $wallet->id, 'provider' => 'mollie', @@ -155,7 +157,8 @@ 'id' => 'test3', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, - 'amount' => 3000, // CHF + 'amount' => 3000, + 'base_amount' => 3000, 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', @@ -166,7 +169,8 @@ 'id' => 'test4', 'description' => '', 'status' => PaymentProvider::STATUS_PAID, - 'amount' => 4000, // CHF + 'amount' => 4000, + 'base_amount' => 4000, 'type' => PaymentProvider::TYPE_RECURRING, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', @@ -177,7 +181,8 @@ 'id' => 'test5', 'description' => '', 'status' => PaymentProvider::STATUS_OPEN, - 'amount' => 5000, // CHF + 'amount' => 5000, + 'base_amount' => 5000, 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', @@ -188,7 +193,8 @@ 'id' => 'test6', 'description' => '', 'status' => PaymentProvider::STATUS_FAILED, - 'amount' => 6000, // CHF + 'amount' => 6000, + 'base_amount' => 6000, 'type' => PaymentProvider::TYPE_ONEOFF, 'wallet_id' => $johns_wallet->id, 'provider' => 'mollie', diff --git a/src/tests/Feature/Controller/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php --- a/src/tests/Feature/Controller/Admin/UsersTest.php +++ b/src/tests/Feature/Controller/Admin/UsersTest.php @@ -236,6 +236,7 @@ 'wallet_id' => $wallet->id, 'status' => 'paid', 'amount' => 1337, + 'base_amount' => 1337, 'description' => 'nonsense transaction for testing', 'provider' => 'self', 'type' => 'oneoff', diff --git a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php --- a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php @@ -657,6 +657,7 @@ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, + 'base_amount' => 123, 'currency_amount' => 123, 'currency' => 'EUR', 'type' => PaymentProvider::TYPE_ONEOFF, diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php --- a/src/tests/Feature/Controller/PaymentsMollieTest.php +++ b/src/tests/Feature/Controller/PaymentsMollieTest.php @@ -716,6 +716,7 @@ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 123, + 'base_amount' => 123, 'currency_amount' => 123, 'currency' => 'CHF', 'type' => PaymentProvider::TYPE_ONEOFF, @@ -874,6 +875,7 @@ 'id' => 'tr_123456', 'status' => PaymentProvider::STATUS_PAID, 'amount' => 1234, + 'base_amount' => 1234, 'currency_amount' => 1117, 'currency' => 'EUR', 'type' => PaymentProvider::TYPE_ONEOFF, diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -180,6 +180,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency' => 'CHF', 'currency_amount' => 1111, ]); diff --git a/src/tests/Feature/Documents/ReceiptTest.php b/src/tests/Feature/Documents/ReceiptTest.php --- a/src/tests/Feature/Documents/ReceiptTest.php +++ b/src/tests/Feature/Documents/ReceiptTest.php @@ -5,6 +5,7 @@ use App\Documents\Receipt; use App\Payment; use App\Providers\PaymentProvider; +use App\TaxRate; use App\User; use App\Wallet; use Carbon\Carbon; @@ -20,6 +21,7 @@ parent::setUp(); Payment::whereIn('id', $this->paymentIDs)->delete(); + TaxRate::truncate(); } /** @@ -30,6 +32,7 @@ $this->deleteTestUser('receipt-test@kolabnow.com'); Payment::whereIn('id', $this->paymentIDs)->delete(); + TaxRate::truncate(); parent::tearDown(); } @@ -121,9 +124,6 @@ */ public function testHtmlOutputVat(): void { - \config(['app.vat.rate' => 7.7]); - \config(['app.vat.countries' => 'ch']); - $appName = \config('app.name'); $wallet = $this->getTestData('CH'); $receipt = new Receipt($wallet, 2020, 5); @@ -214,6 +214,16 @@ $wallet = $user->wallets()->first(); + $vat = 0; + if ($country) { + $vat = 7.7; + TaxRate::create([ + 'country' => $country, + 'rate' => $vat, + 'start' => now(), + ]); + } + // Create two payments out of the 2020-05 period // and three in it, plus one in the period but unpaid, // and one with amount 0, and an extra refund and chanrgeback @@ -226,6 +236,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => 1111, ]); @@ -240,6 +252,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 2222, + 'base_amount' => 2222, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => 2222, ]); @@ -254,6 +268,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 0, + 'base_amount' => 0, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => 0, ]); @@ -268,6 +284,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 990, + 'base_amount' => 990, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => 990, ]); @@ -283,6 +301,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1234, + 'base_amount' => 1234, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => 1234, ]); @@ -297,6 +317,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1, + 'base_amount' => 1, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => 1, ]); @@ -311,6 +333,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 100, + 'base_amount' => 100, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => 100, ]); @@ -325,6 +349,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => -100, + 'base_amount' => -100, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => -100, ]); @@ -339,6 +365,8 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => -10, + 'base_amount' => -10, + 'tax_rate' => $vat, 'currency' => 'CHF', 'currency_amount' => -10, ]); diff --git a/src/tests/Feature/Jobs/PaymentEmailTest.php b/src/tests/Feature/Jobs/PaymentEmailTest.php --- a/src/tests/Feature/Jobs/PaymentEmailTest.php +++ b/src/tests/Feature/Jobs/PaymentEmailTest.php @@ -53,6 +53,7 @@ $payment->id = 'test-payment'; $payment->wallet_id = $wallet->id; $payment->amount = 100; + $payment->base_amount = 100; $payment->currency_amount = 100; $payment->currency = 'CHF'; $payment->status = PaymentProvider::STATUS_PAID; diff --git a/src/tests/Feature/Stories/RateLimitTest.php b/src/tests/Feature/Stories/RateLimitTest.php --- a/src/tests/Feature/Stories/RateLimitTest.php +++ b/src/tests/Feature/Stories/RateLimitTest.php @@ -169,6 +169,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]; @@ -256,6 +257,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]; @@ -399,6 +401,7 @@ 'wallet_id' => $wallet->id, 'provider' => 'stripe', 'amount' => 1111, + 'base_amount' => 1111, 'currency_amount' => 1111, 'currency' => 'CHF', ]; 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 @@ -41,6 +41,7 @@ } Sku::select()->update(['fee' => 0]); + \App\TaxRate::truncate(); } /** @@ -53,6 +54,7 @@ } Sku::select()->update(['fee' => 0]); + \App\TaxRate::truncate(); parent::tearDown(); } @@ -557,6 +559,75 @@ */ } + /** + * 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 + { + \App\TaxRate::create([ + 'start' => now(), + 'country' => 'US', + 'rate' => 7.5, + ]); + \App\TaxRate::create([ + 'start' => now(), + 'country' => 'DE', + 'rate' => 10.0, + ]); + + $user = $this->getTestUser('UserWallet1@UserWallet.com'); + $wallet = $user->wallets()->first(); + + $user->setSetting('country', null); + $this->assertSame(0.0, $wallet->taxRate()); + + $user->setSetting('country', 'DE'); + $this->assertSame(10.0, $wallet->taxRate()); + + $user->setSetting('country', 'US'); + $this->assertSame(7.5, $wallet->taxRate()); + + $user->setSetting('country', 'PL'); + $this->assertSame(0.0, $wallet->taxRate()); + + // Test $start argument + \App\TaxRate::create([ + 'start' => now()->subYear(), + 'country' => 'DE', + 'rate' => 5.0, + ]); + + $user->setSetting('country', 'DE'); + + $this->assertSame(10.0, $wallet->taxRate(now())); + $this->assertSame(5.0, $wallet->taxRate(now()->subMonth())); + $this->assertSame(0.0, $wallet->taxRate(now()->subYears(2))); + } + /** * Tests for updateEntitlements() */