Page MenuHomePhorge

D4076.1775395996.diff
No OneTemporary

Authored By
Unknown
Size
48 KB
Referenced Files
None
Subscribers
None

D4076.1775395996.diff

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

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)

Event Timeline