Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117909910
D4076.1775395996.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
48 KB
Referenced Files
None
Subscribers
None
D4076.1775395996.diff
View Options
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,85 @@
+<?php
+
+namespace App\Console\Commands\Data\Import;
+
+use App\TaxRate;
+use App\Console\Command;
+
+class TaxRatesCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'data:import:tax-rates {file} {date}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Loads tax rates from a file';
+
+ /**
+ * Execute the console command.
+ *
+ * @return mixed
+ */
+ public function handle()
+ {
+ $file = $this->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)) {
+ if (strlen($country) != 2) {
+ $this->info("Invalid country code: {$country}");
+ continue;
+ }
+
+ if (!is_numeric($rate) || $rate < 0 || $rate > 100) {
+ $this->info("Invalid tax rate for {$country}: {$rate}");
+ continue;
+ }
+
+ $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/Console/Commands/Scalpel/TaxRate/CreateCommand.php b/src/app/Console/Commands/Scalpel/TaxRate/CreateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/TaxRate/CreateCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\TaxRate;
+
+use App\Console\ObjectCreateCommand;
+
+class CreateCommand extends ObjectCreateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\TaxRate::class;
+ protected $objectName = 'tax-rate';
+}
diff --git a/src/app/Console/Commands/Scalpel/TaxRate/ReadCommand.php b/src/app/Console/Commands/Scalpel/TaxRate/ReadCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/TaxRate/ReadCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\TaxRate;
+
+use App\Console\ObjectReadCommand;
+
+class ReadCommand extends ObjectReadCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\TaxRate::class;
+ protected $objectName = 'tax-rate';
+}
diff --git a/src/app/Console/Commands/Scalpel/TaxRate/UpdateCommand.php b/src/app/Console/Commands/Scalpel/TaxRate/UpdateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/TaxRate/UpdateCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\TaxRate;
+
+use App\Console\ObjectUpdateCommand;
+
+class UpdateCommand extends ObjectUpdateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\TaxRate::class;
+ protected $objectName = 'tax-rate';
+}
diff --git a/src/app/Console/Commands/TaxRatesCommand.php b/src/app/Console/Commands/TaxRatesCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/TaxRatesCommand.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class TaxRatesCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\TaxRate::class;
+ protected $objectName = 'tax-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 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<int, string> 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<string, string> The attributes that should be cast */
protected $casts = [
- 'amount' => 'integer'
+ 'amount' => 'integer',
+ 'base_amount' => 'integer',
+ 'tax_rate' => 'float',
];
/** @var array<int,string> 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 @@
+<?php
+
+namespace App;
+
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Tax Rate.
+ *
+ * @property string $id Rate identifier (uuid)
+ * @property string $country Two-letter country code
+ * @property float $rate Tax rate
+ * @property string $start Start date of the rate
+ */
+class TaxRate extends Model
+{
+ use UuidStrKeyTrait;
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'start' => 'datetime:Y-m-d H:i:s',
+ 'rate' => 'float'
+ ];
+
+ /** @var array<int, string> 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 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ Schema::create('tax_rates', function (Blueprint $table) {
+ $table->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()
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sun, Apr 5, 1:33 PM (20 h, 7 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18833554
Default Alt Text
D4076.1775395996.diff (48 KB)
Attached To
Mode
D4076: VAT modes
Attached
Detach File
Event Timeline