Page MenuHomePhorge

D4076.1775466546.diff
No OneTemporary

Authored By
Unknown
Size
79 KB
Referenced Files
None
Subscribers
None

D4076.1775466546.diff

diff --git a/src/app/Console/Commands/Data/Import/VatRatesCommand.php b/src/app/Console/Commands/Data/Import/VatRatesCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Data/Import/VatRatesCommand.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Console\Commands\Data\Import;
+
+use App\VatRate;
+use App\Console\Command;
+
+class VatRatesCommand extends Command
+{
+ /**
+ * The name and signature of the console command.
+ *
+ * @var string
+ */
+ protected $signature = 'data:import:vat-rates {file} {date}';
+
+ /**
+ * The console command description.
+ *
+ * @var string
+ */
+ protected $description = 'Loads VAT 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 VAT rate for {$country}: {$rate}");
+ continue;
+ }
+
+ $existing = VatRate::where('country', $country)
+ ->where('start', '<=', $date)
+ ->limit(1)
+ ->first();
+
+ if (!$existing || $existing->rate != $rate) {
+ VatRate::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/VatRate/CreateCommand.php b/src/app/Console/Commands/Scalpel/VatRate/CreateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/VatRate/CreateCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\VatRate;
+
+use App\Console\ObjectCreateCommand;
+
+class CreateCommand extends ObjectCreateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\VatRate::class;
+ protected $objectName = 'vat-rate';
+}
diff --git a/src/app/Console/Commands/Scalpel/VatRate/ReadCommand.php b/src/app/Console/Commands/Scalpel/VatRate/ReadCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/VatRate/ReadCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\VatRate;
+
+use App\Console\ObjectReadCommand;
+
+class ReadCommand extends ObjectReadCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\VatRate::class;
+ protected $objectName = 'vat-rate';
+}
diff --git a/src/app/Console/Commands/Scalpel/VatRate/UpdateCommand.php b/src/app/Console/Commands/Scalpel/VatRate/UpdateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/VatRate/UpdateCommand.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\VatRate;
+
+use App\Console\ObjectUpdateCommand;
+
+class UpdateCommand extends ObjectUpdateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\VatRate::class;
+ protected $objectName = 'vat-rate';
+}
diff --git a/src/app/Console/Commands/VatRatesCommand.php b/src/app/Console/Commands/VatRatesCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/VatRatesCommand.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Console\ObjectListCommand;
+
+class VatRatesCommand extends ObjectListCommand
+{
+ protected $objectClass = \App\VatRate::class;
+ protected $objectName = 'vat-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,15 @@
'updated_at' => $start->copy()->next()->next()->next(),
],
]);
+
+ $items = $items->map(function ($payment) {
+ $payment->vatRate = new \App\VatRate();
+ $payment->vatRate->rate = 7.7;
+ $payment->credit_amount = $payment->amount + round($payment->amount * $payment->vatRate->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 +170,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->vatRate && $item->vatRate->rate > 0) {
+ $vat = round($item->credit_amount * $item->vatRate->rate / 100);
+ $amount -= $vat;
+ $totalVat += $vat;
+ $vatRate = $item->vatRate->rate; // TODO: Multiple rates
}
$total += $amount;
diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
@@ -111,7 +111,7 @@
// as I believe this way we have more precise amounts for this use-case (and default currency)
$query = DB::table('payments')
- ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(amount) as amount, wallets.currency")
+ ->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(credit_amount) as amount, wallets.currency")
->join('wallets', 'wallets.id', '=', 'wallet_id')
->where('updated_at', '>=', $start->toDateString())
->where('status', PaymentProvider::STATUS_PAID)
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
@@ -63,6 +63,8 @@
// we'll top-up the wallet with the configured auto-payment amount
if ($wallet->balance < intval($request->balance * 100)) {
$mandate['amount'] = intval($request->amount * 100);
+
+ self::addTax($wallet, $mandate);
}
$provider = PaymentProvider::factory($wallet);
@@ -227,6 +229,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 +344,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 +490,30 @@
'page' => $page,
]);
}
+
+ /**
+ * Calculates tax for the payment, fills the request with additional properties
+ */
+ protected static function addTax(Wallet $wallet, array &$request): void
+ {
+ $request['vat_rate_id'] = null;
+ $request['credit_amount'] = $request['amount'];
+
+ if ($rate = $wallet->vatRate()) {
+ $request['vat_rate_id'] = $rate->id;
+
+ 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->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
@@ -2,27 +2,38 @@
namespace App;
+use App\Providers\PaymentProvider;
+use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
/**
* A payment operation on a wallet.
*
- * @property int $amount Amount of money in cents of system currency
- * @property string $description Payment description
- * @property string $id Mollie's Payment ID
- * @property \App\Wallet $wallet The wallet
- * @property string $wallet_id The ID of the wallet
- * @property string $currency Currency of this payment
- * @property int $currency_amount Amount of money in cents of $currency
+ * @property int $amount Amount of money in cents of system currency (payment provider)
+ * @property int $credit_amount Amount of money in cents of system currency (wallet balance)
+ * @property string $description Payment description
+ * @property string $id Mollie's Payment ID
+ * @property ?string $vat_rate_id VAT rate identifier
+ * @property \App\Wallet $wallet The wallet
+ * @property string $wallet_id The ID of the wallet
+ * @property string $currency Currency of this payment
+ * @property int $currency_amount Amount of money in cents of $currency
*/
class Payment extends Model
{
+ use NullableFields;
+
+ /** @var bool Indicates that the model should be timestamped or not */
public $incrementing = false;
+
+ /** @var string The "type" of the auto-incrementing ID */
protected $keyType = 'string';
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
- 'amount' => 'integer'
+ 'amount' => 'integer',
+ 'credit_amount' => 'integer',
+ 'currency_amount' => 'integer',
];
/** @var array<int,string> The attributes that are mass assignable */
@@ -30,14 +41,90 @@
'id',
'wallet_id',
'amount',
+ 'credit_amount',
'description',
'provider',
'status',
+ 'vat_rate_id',
'type',
'currency',
'currency_amount',
];
+ /** @var array<int, string> The attributes that can be not set */
+ protected $nullable = [
+ 'vat_rate_id',
+ ];
+
+
+ /**
+ * Create a payment record in DB from array.
+ *
+ * @param array $payment Payment information (required: id, type, wallet_id, currency, amount, currency_amount)
+ *
+ * @return \App\Payment Payment object
+ */
+ public static function createFromArray(array $payment): Payment
+ {
+ $db_payment = new Payment();
+ $db_payment->id = $payment['id'];
+ $db_payment->description = $payment['description'] ?? '';
+ $db_payment->status = $payment['status'] ?? PaymentProvider::STATUS_OPEN;
+ $db_payment->amount = $payment['amount'] ?? 0;
+ $db_payment->credit_amount = $payment['credit_amount'] ?? ($payment['amount'] ?? 0);
+ $db_payment->vat_rate_id = $payment['vat_rate_id'] ?? null;
+ $db_payment->type = $payment['type'];
+ $db_payment->wallet_id = $payment['wallet_id'];
+ $db_payment->provider = $payment['provider'] ?? '';
+ $db_payment->currency = $payment['currency'];
+ $db_payment->currency_amount = $payment['currency_amount'];
+ $db_payment->save();
+
+ return $db_payment;
+ }
+
+ /**
+ * Creates a payment and transaction records for the refund/chargeback operation.
+ * Deducts an amount of pecunia from the wallet.
+ *
+ * @param array $refund A refund or chargeback data (id, type, amount, currency, description)
+ *
+ * @return ?\App\Payment A payment object for the refund
+ */
+ public function refund(array $refund): ?Payment
+ {
+ if (empty($refund) || empty($refund['amount'])) {
+ return null;
+ }
+
+ // Convert amount to wallet currency (use the same exchange rate as for the original payment)
+ // Note: We assume a refund is always using the same currency
+ $exchange_rate = $this->amount / $this->currency_amount;
+ $credit_amount = $amount = (int) round($refund['amount'] * $exchange_rate);
+
+ // Set appropriate credit_amount if original credit_amount != original amount
+ if ($this->amount != $this->credit_amount) {
+ $credit_amount = (int) round($amount * ($this->credit_amount / $this->amount));
+ }
+
+ // Apply the refund to the wallet balance
+ $method = $refund['type'] == PaymentProvider::TYPE_CHARGEBACK ? 'chargeback' : 'refund';
+
+ $this->wallet->{$method}($credit_amount, $refund['description'] ?? '');
+
+ $refund['amount'] = $amount * -1;
+ $refund['credit_amount'] = $credit_amount * -1;
+ $refund['currency_amount'] = round($amount * -1 / $exchange_rate);
+ $refund['currency'] = $this->currency;
+ $refund['wallet_id'] = $this->wallet_id;
+ $refund['provider'] = $this->provider;
+ $refund['vat_rate_id'] = $this->vat_rate_id;
+ $refund['status'] = PaymentProvider::STATUS_PAID;
+
+ // FIXME: Refunds/chargebacks are out of the reseller comissioning for now
+
+ return self::createFromArray($refund);
+ }
/**
* Ensure the currency is appropriately cased.
@@ -56,4 +143,14 @@
{
return $this->belongsTo(Wallet::class, 'wallet_id', 'id');
}
+
+ /**
+ * The VAT rate assigned to this payment.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function vatRate()
+ {
+ return $this->belongsTo(VatRate::class, 'vat_rate_id', 'id');
+ }
}
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, $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
@@ -436,7 +436,7 @@
}
foreach ($refunds as $refund) {
- $this->storeRefund($payment->wallet, $refund);
+ $payment->refund($refund);
}
DB::commit();
@@ -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, $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
@@ -83,7 +83,9 @@
$session = StripeAPI\Checkout\Session::create($request);
$payment['amount'] = 0;
+ $payment['credit_amount'] = 0;
$payment['currency_amount'] = 0;
+ $payment['vat_rate_id'] = null;
$payment['id'] = $session->setup_intent;
$payment['type'] = self::TYPE_MANDATE;
@@ -182,7 +184,6 @@
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
-
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
@@ -454,7 +455,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, $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
@@ -100,7 +100,9 @@
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
- * - amount: Value in cents
+ * - amount: Value in cents (wallet currency)
+ * - credit_amount: Balance'able base amount in cents (wallet currency)
+ * - vat_rate_id: VAT rate id
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
@@ -155,7 +157,9 @@
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
- * - amount: Value in cents
+ * - amount: Value in cents (wallet currency)
+ * - credit_amount: Balance'able base amount in cents (wallet currency)
+ * - vat_rate_id: Vat rate id
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
@@ -184,19 +188,10 @@
*/
protected function storePayment(array $payment, $wallet_id): Payment
{
- $db_payment = new Payment();
- $db_payment->id = $payment['id'];
- $db_payment->description = $payment['description'] ?? '';
- $db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
- $db_payment->amount = $payment['amount'] ?? 0;
- $db_payment->type = $payment['type'];
- $db_payment->wallet_id = $wallet_id;
- $db_payment->provider = $this->name();
- $db_payment->currency = $payment['currency'];
- $db_payment->currency_amount = $payment['currency_amount'];
- $db_payment->save();
-
- return $db_payment;
+ $payment['wallet_id'] = $wallet_id;
+ $payment['provider'] = $this->name();
+
+ return Payment::createFromArray($payment);
}
/**
@@ -213,53 +208,6 @@
return intval(round($amount * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency)));
}
- /**
- * Deduct an amount of pecunia from the wallet.
- * Creates a payment and transaction records for the refund/chargeback operation.
- *
- * @param \App\Wallet $wallet A wallet object
- * @param array $refund A refund or chargeback data (id, type, amount, description)
- *
- * @return void
- */
- protected function storeRefund(Wallet $wallet, array $refund): void
- {
- if (empty($refund) || empty($refund['amount'])) {
- return;
- }
-
- // Preserve originally refunded amount
- $refund['currency_amount'] = $refund['amount'] * -1;
-
- // Convert amount to wallet currency
- // 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();
-
- if ($refund['type'] == self::TYPE_CHARGEBACK) {
- $transaction_type = Transaction::WALLET_CHARGEBACK;
- } else {
- $transaction_type = Transaction::WALLET_REFUND;
- }
-
- Transaction::create([
- 'object_id' => $wallet->id,
- 'object_type' => Wallet::class,
- 'type' => $transaction_type,
- 'amount' => $amount * -1,
- 'description' => $refund['description'] ?? '',
- ]);
-
- $refund['status'] = self::STATUS_PAID;
- $refund['amount'] = -1 * $amount;
-
- // FIXME: Refunds/chargebacks are out of the reseller comissioning for now
-
- $this->storePayment($refund, $wallet->id);
- }
-
/**
* List supported payment methods from this provider
*
diff --git a/src/app/User.php b/src/app/User.php
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -787,7 +787,7 @@
// Check 2FA - Companion App
if (!$error && $user->mfaEnabled()) {
- $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
+ $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP);
if (!$attempt->waitFor2FA()) {
$error = AuthAttempt::REASON_2FA;
}
@@ -796,7 +796,7 @@
if ($error) {
if ($user && empty($attempt)) {
- $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
+ $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP);
if (!$attempt->isAccepted()) {
$attempt->deny($error);
$attempt->save();
diff --git a/src/app/VatRate.php b/src/app/VatRate.php
new file mode 100644
--- /dev/null
+++ b/src/app/VatRate.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace App;
+
+use App\Traits\UuidStrKeyTrait;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Vat 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 VatRate 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|\App\Payment $amount The amount of award (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function award(int|Payment $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|\App\Payment $amount The amount of pecunia to charge back (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function chargeback(int|Payment $amount, string $description = ''): Wallet
+ {
+ return $this->balanceUpdate(Transaction::WALLET_CHARGEBACK, $amount, $description);
+ }
+
/**
* Controllers of this wallet.
*
@@ -247,64 +363,28 @@
/**
* Add an amount of pecunia to this wallet's balance.
*
- * @param int $amount The amount of pecunia to add (in cents).
- * @param string $description The transaction description
+ * @param int|\App\Payment $amount The amount of pecunia to add (in cents) or Payment object
+ * @param string $description The transaction description
*
* @return Wallet Self
*/
- public function credit(int $amount, string $description = ''): Wallet
+ public function credit(int|Payment $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);
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
- * @param int $amount The amount of pecunia to deduct (in cents).
- * @param string $description The transaction description
- * @param array $eTIDs List of transaction IDs for the individual entitlements
- * that make up this debit record, if any.
+ * @param int|\App\Payment $amount The amount of pecunia to deduct (in cents) or Payment object
+ * @param string $description The transaction description
+ * @param array $eTIDs List of transaction IDs for the individual entitlements
+ * that make up this debit record, if any.
* @return Wallet Self
*/
- public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet
+ public function debit(int|Payment $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|\App\Payment $amount The amount of penalty (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function penalty(int|Payment $amount, string $description = ''): Wallet
+ {
+ return $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description);
+ }
+
/**
* Plan of the wallet.
*
@@ -436,6 +529,49 @@
}
}
+ /**
+ * Refund an amount of pecunia from this wallet's balance.
+ *
+ * @param int|\App\Payment $amount The amount of pecunia to refund (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function refund($amount, string $description = ''): Wallet
+ {
+ return $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description);
+ }
+
+ /**
+ * Get the VAT 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 ?\App\VatRate VAT rate
+ */
+ public function vatRate(\DateTime $start = null): ?VatRate
+ {
+ $owner = $this->owner;
+
+ // Make it working with deleted accounts too
+ if (!$owner) {
+ $owner = $this->owner()->withTrashed()->first();
+ }
+
+ $country = $owner->getSetting('country');
+
+ if (!$country) {
+ return null;
+ }
+
+ return VatRate::where('country', $country)
+ ->where('start', '<=', ($start ?: now())->format('Y-m-d h:i:s'))
+ ->orderByDesc('start')
+ ->limit(1)
+ ->first();
+ }
+
/**
* Retrieve the transactions against this wallet.
*
@@ -556,4 +692,42 @@
return $charges;
}
+
+ /**
+ * Update the wallet balance, and create a transaction record
+ */
+ protected function balanceUpdate(string $type, int|Payment $amount, $description = null, array $eTIDs = [])
+ {
+ if ($amount instanceof Payment) {
+ $amount = $amount->credit_amount;
+ }
+
+ 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_vat_rates_table.php b/src/database/migrations/2023_02_17_100000_vat_rates_table.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2023_02_17_100000_vat_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('vat_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) {
+ $table->string('vat_rate_id', 36)->nullable();
+ $table->integer('credit_amount')->nullable(); // temporarily allow null
+
+ $table->foreign('vat_rate_id')->references('id')->on('vat_rates')->onUpdate('cascade');
+ }
+ );
+
+ DB::table('payments')->update(['credit_amount' => DB::raw("`amount`")]);
+
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->integer('credit_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) {
+ $vatRate = \App\VatRate::create([
+ 'start' => new DateTime('2010-01-01 00:00:00'),
+ 'rate' => $rate,
+ 'country' => $country,
+ ]);
+
+ DB::table('payments')->whereIn('wallet_id', function ($query) use ($country) {
+ $query->select('id')
+ ->from('wallets')
+ ->whereIn('user_id', function ($query) use ($country) {
+ $query->select('user_id')
+ ->from('user_settings')
+ ->where('key', 'country')
+ ->where('value', $country);
+ });
+ })
+ ->update(['vat_rate_id' => $vatRate->id]);
+ }
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->dropColumn('vat_rate_id');
+ $table->dropColumn('credit_amount');
+ }
+ );
+
+ Schema::dropIfExists('vat_rates');
+ }
+};
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,
+ 'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
]);
@@ -154,6 +155,7 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
+ 'credit_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,
+ 'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
]);
@@ -147,6 +148,7 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
+ 'credit_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,
+ 'credit_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
@@ -18,7 +18,7 @@
parent::setUp();
self::useAdminUrl();
- Payment::truncate();
+ Payment::query()->delete();
DB::table('wallets')->update(['discount_id' => null]);
$this->deleteTestUser('test-stats@' . \config('app.domain'));
@@ -29,7 +29,7 @@
*/
public function tearDown(): void
{
- Payment::truncate();
+ Payment::query()->delete();
DB::table('wallets')->update(['discount_id' => null]);
$this->deleteTestUser('test-stats@' . \config('app.domain'));
@@ -133,7 +133,8 @@
'id' => 'test1',
'description' => '',
'status' => PaymentProvider::STATUS_PAID,
- 'amount' => 1000, // EUR
+ 'amount' => 1000,
+ 'credit_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,
+ 'credit_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,
+ 'credit_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,
+ 'credit_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,
+ 'credit_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,
+ 'credit_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
@@ -24,6 +24,9 @@
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
+
+ \App\SharedFolderAlias::truncate();
+ \App\Payment::query()->delete();
}
/**
@@ -39,6 +42,7 @@
$jack->setSetting('external_email', null);
\App\SharedFolderAlias::truncate();
+ \App\Payment::query()->delete();
parent::tearDown();
}
@@ -236,6 +240,7 @@
'wallet_id' => $wallet->id,
'status' => 'paid',
'amount' => 1337,
+ 'credit_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,
+ 'credit_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
@@ -8,6 +8,7 @@
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
+use App\VatRate;
use App\Utils;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
@@ -29,11 +30,15 @@
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
-
+ \config(['app.vat.mode' => 0]);
Utils::setTestExchangeRates(['EUR' => '0.90503424978382']);
+
+ $this->deleteTestUser('payment-test@' . \config('app.domain'));
+
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- Payment::where('wallet_id', $wallet->id)->delete();
+ Payment::query()->delete();
+ VatRate::query()->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
@@ -49,9 +54,12 @@
*/
public function tearDown(): void
{
+ $this->deleteTestUser('payment-test@' . \config('app.domain'));
+
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- Payment::where('wallet_id', $wallet->id)->delete();
+ Payment::query()->delete();
+ VatRate::query()->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
@@ -537,8 +545,7 @@
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
- $result = PaymentsController::topUpWallet($wallet);
- $this->assertTrue($result);
+ $this->assertTrue(PaymentsController::topUpWallet($wallet));
// Check that the payments table contains a new record with proper amount.
// There should be two records, one for the mandate payment and another for
@@ -696,6 +703,70 @@
$this->unmockMollie();
}
+ /**
+ * Test payment/top-up with VAT_MODE=1
+ *
+ * @group mollie
+ */
+ public function testPaymentsWithVatModeOne(): void
+ {
+ \config(['app.vat.mode' => 1]);
+
+ $user = $this->getTestUser('payment-test@' . \config('app.domain'));
+ $user->setSetting('country', 'US');
+ $wallet = $user->wallets()->first();
+ $vatRate = VatRate::create([
+ 'country' => 'US',
+ 'rate' => 5.0,
+ 'start' => now()->subDay(),
+ ]);
+
+ // Payment
+ $post = ['amount' => '10', 'currency' => 'CHF', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ // Check that the payments table contains a new record with proper amount(s)
+ $payment = $wallet->payments()->first();
+ $this->assertSame(1000 + intval(round(1000 * $vatRate->rate / 100)), $payment->amount);
+ $this->assertSame(1000, $payment->credit_amount);
+ $this->assertSame($payment->amount, $payment->currency_amount);
+ $this->assertSame('CHF', $payment->currency);
+ $this->assertSame($vatRate->id, $payment->vat_rate_id);
+ $this->assertSame('open', $payment->status);
+
+ $wallet->payments()->delete();
+ $wallet->balance = -1000;
+ $wallet->save();
+
+ // Top-up (mandate creation)
+ // Create a valid mandate first (expect an extra payment)
+ $this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
+
+ // Check that the payments table contains a new record with proper amount(s)
+ $payment = $wallet->payments()->first();
+ $this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
+ $this->assertSame(2010, $payment->credit_amount);
+ $this->assertSame($payment->amount, $payment->currency_amount);
+ $this->assertSame($vatRate->id, $payment->vat_rate_id);
+
+ $wallet->payments()->delete();
+ $wallet->balance = -1000;
+ $wallet->save();
+
+ // Top-up (recurring payment)
+ // Expect a recurring payment as we have a valid mandate at this point
+ // and the balance is below the threshold
+ $this->assertTrue(PaymentsController::topUpWallet($wallet));
+
+ // Check that the payments table contains a new record with proper amount(s)
+ $payment = $wallet->payments()->first();
+ $this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
+ $this->assertSame(2010, $payment->credit_amount);
+ $this->assertSame($payment->amount, $payment->currency_amount);
+ $this->assertSame($vatRate->id, $payment->vat_rate_id);
+ }
+
/**
* Test refund/chargeback handling by the webhook
*
@@ -716,6 +787,7 @@
'id' => 'tr_123456',
'status' => PaymentProvider::STATUS_PAID,
'amount' => 123,
+ 'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'CHF',
'type' => PaymentProvider::TYPE_ONEOFF,
@@ -874,6 +946,7 @@
'id' => 'tr_123456',
'status' => PaymentProvider::STATUS_PAID,
'amount' => 1234,
+ 'credit_amount' => 1234,
'currency_amount' => 1117,
'currency' => 'EUR',
'type' => PaymentProvider::TYPE_ONEOFF,
@@ -967,7 +1040,6 @@
$this->stopBrowser();
}
-
/**
* Test listing a pending payment
*
diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php
--- a/src/tests/Feature/Controller/PaymentsStripeTest.php
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -8,6 +8,7 @@
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
+use App\VatRate;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
@@ -26,14 +27,18 @@
// All tests in this file use Stripe
\config(['services.payment_provider' => 'stripe']);
+ \config(['app.vat.mode' => 0]);
+
+ $this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_CREDIT)->delete();
+ Payment::query()->delete();
+ VatRate::query()->delete();
}
/**
@@ -41,13 +46,16 @@
*/
public function tearDown(): void
{
+ $this->deleteTestUser('payment-test@' . \config('app.domain'));
+
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
- Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_CREDIT)->delete();
+ Payment::query()->delete();
+ VatRate::query()->delete();
parent::tearDown();
}
@@ -699,18 +707,109 @@
}
/**
- * Generate Stripe-Signature header for a webhook payload
+ * Test payment/top-up with VAT_MODE=1
+ *
+ * @group stripe
*/
- protected function webhookRequest($post)
+ public function testPaymentsWithVatModeOne(): void
{
- $secret = \config('services.stripe.webhook_secret');
- $ts = time();
+ \config(['app.vat.mode' => 1]);
- $payload = "$ts." . json_encode($post);
- $sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret));
+ $user = $this->getTestUser('payment-test@' . \config('app.domain'));
+ $user->setSetting('country', 'US');
+ $wallet = $user->wallets()->first();
+ $vatRate = VatRate::create([
+ 'country' => 'US',
+ 'rate' => 5.0,
+ 'start' => now()->subDay(),
+ ]);
- return $this->withHeaders(['Stripe-Signature' => $sig])
- ->json('POST', "api/webhooks/payment/stripe", $post);
+ // Payment
+ $post = ['amount' => '10', 'currency' => 'CHF', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($user)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ // Check that the payments table contains a new record with proper amount(s)
+ $payment = $wallet->payments()->first();
+ $this->assertSame(1000 + intval(round(1000 * $vatRate->rate / 100)), $payment->amount);
+ $this->assertSame(1000, $payment->credit_amount);
+ $this->assertSame($payment->amount, $payment->currency_amount);
+ $this->assertSame('CHF', $payment->currency);
+ $this->assertSame($vatRate->id, $payment->vat_rate_id);
+ $this->assertSame('open', $payment->status);
+
+ $wallet->payments()->delete();
+ $wallet->balance = -1000;
+ $wallet->save();
+
+ // Top-up (mandate creation)
+ // Create a valid mandate first (expect an extra payment)
+ $post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
+ $response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ // Check that the payments table contains a new record with proper amount(s)
+ // Stripe mandates always use amount=0
+ $payment = $wallet->payments()->first();
+ $this->assertSame(0, $payment->amount);
+ $this->assertSame(0, $payment->credit_amount);
+ $this->assertSame(0, $payment->currency_amount);
+ $this->assertSame(null, $payment->vat_rate_id);
+
+ $wallet->payments()->delete();
+ $wallet->balance = -1000;
+ $wallet->save();
+
+ // Top-up (recurring payment)
+ // Expect a recurring payment as we have a valid mandate at this point
+ // and the balance is below the threshold
+ $wallet->setSettings(['stripe_mandate_id' => 'AAA']);
+ $setupIntent = json_encode([
+ "id" => "AAA",
+ "object" => "setup_intent",
+ "created" => 123456789,
+ "payment_method" => "pm_YYY",
+ "status" => "succeeded",
+ "usage" => "off_session",
+ "customer" => null
+ ]);
+
+ $paymentMethod = json_encode([
+ "id" => "pm_YYY",
+ "object" => "payment_method",
+ "card" => [
+ "brand" => "visa",
+ "country" => "US",
+ "last4" => "4242"
+ ],
+ "created" => 123456789,
+ "type" => "card"
+ ]);
+
+ $paymentIntent = json_encode([
+ "id" => "pi_XX",
+ "object" => "payment_intent",
+ "created" => 123456789,
+ "amount" => 2010 + intval(round(2010 * $vatRate->rate / 100)),
+ "currency" => "chf",
+ "description" => "Recurring Payment"
+ ]);
+
+ $client = $this->mockStripe();
+ $client->addResponse($setupIntent);
+ $client->addResponse($paymentMethod);
+ $client->addResponse($setupIntent);
+ $client->addResponse($paymentIntent);
+
+ $result = PaymentsController::topUpWallet($wallet);
+ $this->assertTrue($result);
+
+ // Check that the payments table contains a new record with proper amount(s)
+ $payment = $wallet->payments()->first();
+ $this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
+ $this->assertSame(2010, $payment->credit_amount);
+ $this->assertSame($payment->amount, $payment->currency_amount);
+ $this->assertSame($vatRate->id, $payment->vat_rate_id);
}
/**
@@ -742,4 +841,19 @@
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
}
+
+ /**
+ * Generate Stripe-Signature header for a webhook payload
+ */
+ protected function webhookRequest($post)
+ {
+ $secret = \config('services.stripe.webhook_secret');
+ $ts = time();
+
+ $payload = "$ts." . json_encode($post);
+ $sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret));
+
+ return $this->withHeaders(['Stripe-Signature' => $sig])
+ ->json('POST', "api/webhooks/payment/stripe", $post);
+ }
}
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,
+ 'credit_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
@@ -7,19 +7,19 @@
use App\Providers\PaymentProvider;
use App\User;
use App\Wallet;
+use App\VatRate;
use Carbon\Carbon;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class ReceiptTest extends TestCase
{
- private $paymentIDs = ['AAA1', 'AAA2', 'AAA3', 'AAA4', 'AAA5', 'AAA6', 'AAA7'];
-
public function setUp(): void
{
parent::setUp();
- Payment::whereIn('id', $this->paymentIDs)->delete();
+ Payment::query()->delete();
+ VatRate::query()->delete();
}
/**
@@ -29,7 +29,8 @@
{
$this->deleteTestUser('receipt-test@kolabnow.com');
- Payment::whereIn('id', $this->paymentIDs)->delete();
+ Payment::query()->delete();
+ VatRate::query()->delete();
parent::tearDown();
}
@@ -121,9 +122,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 +212,15 @@
$wallet = $user->wallets()->first();
+ $vat = null;
+ if ($country) {
+ $vat = VatRate::create([
+ 'country' => $country,
+ 'rate' => 7.7,
+ 'start' => now(),
+ ])->id;
+ }
+
// 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 +233,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
+ 'credit_amount' => 1111,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 1111,
]);
@@ -240,6 +249,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 2222,
+ 'credit_amount' => 2222,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 2222,
]);
@@ -254,6 +265,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 0,
+ 'credit_amount' => 0,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 0,
]);
@@ -268,6 +281,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 990,
+ 'credit_amount' => 990,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 990,
]);
@@ -283,6 +298,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1234,
+ 'credit_amount' => 1234,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 1234,
]);
@@ -297,6 +314,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1,
+ 'credit_amount' => 1,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 1,
]);
@@ -311,6 +330,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 100,
+ 'credit_amount' => 100,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 100,
]);
@@ -325,6 +346,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => -100,
+ 'credit_amount' => -100,
+ 'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => -100,
]);
@@ -339,6 +362,8 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => -10,
+ 'credit_amount' => -10,
+ 'vat_rate_id' => $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->credit_amount = 100;
$payment->currency_amount = 100;
$payment->currency = 'CHF';
$payment->status = PaymentProvider::STATUS_PAID;
diff --git a/src/tests/Feature/PaymentTest.php b/src/tests/Feature/PaymentTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/PaymentTest.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\Transaction;
+use App\Wallet;
+use App\VatRate;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class PaymentTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('jane@kolabnow.com');
+ Payment::query()->delete();
+ VatRate::query()->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('jane@kolabnow.com');
+ Payment::query()->delete();
+ VatRate::query()->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test createFromArray() and refund() methods
+ */
+ public function testCreateAndRefund(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('jane@kolabnow.com');
+ $wallet = $user->wallets()->first();
+
+ $vatRate = VatRate::create([
+ 'start' => now()->subDay(),
+ 'country' => 'US',
+ 'rate' => 7.5,
+ ]);
+
+ // Test required properties only
+ $payment1Array = [
+ 'id' => 'test-payment2',
+ 'amount' => 10750,
+ 'currency' => 'USD',
+ 'currency_amount' => 9000,
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'wallet_id' => $wallet->id,
+ ];
+
+ $payment1 = Payment::createFromArray($payment1Array);
+
+ $this->assertSame($payment1Array['id'], $payment1->id);
+ $this->assertSame('', $payment1->provider);
+ $this->assertSame('', $payment1->description);
+ $this->assertSame(null, $payment1->vat_rate_id);
+ $this->assertSame($payment1Array['amount'], $payment1->amount);
+ $this->assertSame($payment1Array['amount'], $payment1->credit_amount);
+ $this->assertSame($payment1Array['currency_amount'], $payment1->currency_amount);
+ $this->assertSame($payment1Array['currency'], $payment1->currency);
+ $this->assertSame($payment1Array['type'], $payment1->type);
+ $this->assertSame(PaymentProvider::STATUS_OPEN, $payment1->status);
+ $this->assertSame($payment1Array['wallet_id'], $payment1->wallet_id);
+ $this->assertCount(1, Payment::where('id', $payment1->id)->get());
+
+ // Test settable all properties
+ $payment2Array = [
+ 'id' => 'test-payment',
+ 'provider' => 'mollie',
+ 'description' => 'payment description',
+ 'vat_rate_id' => $vatRate->id,
+ 'amount' => 10750,
+ 'credit_amount' => 10000,
+ 'currency' => $wallet->currency,
+ 'currency_amount' => 10750,
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => PaymentProvider::STATUS_OPEN,
+ 'wallet_id' => $wallet->id,
+ ];
+
+ $payment2 = Payment::createFromArray($payment2Array);
+
+ $this->assertSame($payment2Array['id'], $payment2->id);
+ $this->assertSame($payment2Array['provider'], $payment2->provider);
+ $this->assertSame($payment2Array['description'], $payment2->description);
+ $this->assertSame($payment2Array['vat_rate_id'], $payment2->vat_rate_id);
+ $this->assertSame($payment2Array['amount'], $payment2->amount);
+ $this->assertSame($payment2Array['credit_amount'], $payment2->credit_amount);
+ $this->assertSame($payment2Array['currency_amount'], $payment2->currency_amount);
+ $this->assertSame($payment2Array['currency'], $payment2->currency);
+ $this->assertSame($payment2Array['type'], $payment2->type);
+ $this->assertSame($payment2Array['status'], $payment2->status);
+ $this->assertSame($payment2Array['wallet_id'], $payment2->wallet_id);
+ $this->assertSame($vatRate->id, $payment2->vatRate->id);
+ $this->assertCount(1, Payment::where('id', $payment2->id)->get());
+
+ $refundArray = [
+ 'id' => 'test-refund',
+ 'type' => PaymentProvider::TYPE_CHARGEBACK,
+ 'description' => 'test refund desc',
+ ];
+
+ // Refund amount is required
+ $this->assertNull($payment2->refund($refundArray));
+
+ // All needed info
+ $refundArray['amount'] = 5000;
+
+ $refund = $payment2->refund($refundArray);
+
+ $this->assertSame($refundArray['id'], $refund->id);
+ $this->assertSame($refundArray['description'], $refund->description);
+ $this->assertSame(-5000, $refund->amount);
+ $this->assertSame(-4651, $refund->credit_amount);
+ $this->assertSame(-5000, $refund->currency_amount);
+ $this->assertSame($refundArray['type'], $refund->type);
+ $this->assertSame(PaymentProvider::STATUS_PAID, $refund->status);
+ $this->assertSame($payment2->currency, $refund->currency);
+ $this->assertSame($payment2->provider, $refund->provider);
+ $this->assertSame($payment2->wallet_id, $refund->wallet_id);
+ $this->assertSame($payment2->vat_rate_id, $refund->vat_rate_id);
+ $wallet->refresh();
+ $this->assertSame(-4651, $wallet->balance);
+ $transaction = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->first();
+ $this->assertSame(-4651, $transaction->amount);
+ $this->assertSame($refundArray['description'], $transaction->description);
+
+ $wallet->balance = 0;
+ $wallet->save();
+
+ // Test non-wallet currency
+ $refundArray['id'] = 'test-refund-2';
+ $refundArray['amount'] = 9000;
+ $refundArray['type'] = PaymentProvider::TYPE_REFUND;
+
+ $refund = $payment1->refund($refundArray);
+
+ $this->assertSame($refundArray['id'], $refund->id);
+ $this->assertSame($refundArray['description'], $refund->description);
+ $this->assertSame(-10750, $refund->amount);
+ $this->assertSame(-10750, $refund->credit_amount);
+ $this->assertSame(-9000, $refund->currency_amount);
+ $this->assertSame($refundArray['type'], $refund->type);
+ $this->assertSame(PaymentProvider::STATUS_PAID, $refund->status);
+ $this->assertSame($payment1->currency, $refund->currency);
+ $this->assertSame($payment1->provider, $refund->provider);
+ $this->assertSame($payment1->wallet_id, $refund->wallet_id);
+ $this->assertSame($payment1->vat_rate_id, $refund->vat_rate_id);
+ $wallet->refresh();
+ $this->assertSame(-10750, $wallet->balance);
+ $transaction = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->first();
+ $this->assertSame(-10750, $transaction->amount);
+ $this->assertSame($refundArray['description'], $transaction->description);
+ }
+}
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
@@ -19,10 +19,14 @@
$this->setUpTest();
$this->useServicesUrl();
+
+ \App\Payment::query()->delete();
}
public function tearDown(): void
{
+ \App\Payment::query()->delete();
+
parent::tearDown();
}
@@ -169,6 +173,7 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
+ 'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
];
@@ -256,6 +261,7 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
+ 'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
];
@@ -399,6 +405,7 @@
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
+ 'credit_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
@@ -2,12 +2,14 @@
namespace Tests\Feature;
+use App\Payment;
use App\Package;
use App\Plan;
use App\User;
use App\Sku;
use App\Transaction;
use App\Wallet;
+use App\VatRate;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
@@ -41,6 +43,8 @@
}
Sku::select()->update(['fee' => 0]);
+ Payment::query()->delete();
+ VatRate::query()->delete();
}
/**
@@ -53,6 +57,8 @@
}
Sku::select()->update(['fee' => 0]);
+ Payment::query()->delete();
+ VatRate::query()->delete();
parent::tearDown();
}
@@ -557,6 +563,30 @@
*/
}
+ /**
+ * 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 updateEntitlements()
*/
@@ -564,4 +594,47 @@
{
$this->markTestIncomplete();
}
+
+ /**
+ * Tests for vatRate()
+ */
+ public function testVatRate(): void
+ {
+ $rate1 = VatRate::create([
+ 'start' => now()->subDay(),
+ 'country' => 'US',
+ 'rate' => 7.5,
+ ]);
+ $rate2 = VatRate::create([
+ 'start' => now()->subDay(),
+ 'country' => 'DE',
+ 'rate' => 10.0,
+ ]);
+
+ $user = $this->getTestUser('UserWallet1@UserWallet.com');
+ $wallet = $user->wallets()->first();
+
+ $user->setSetting('country', null);
+ $this->assertSame(null, $wallet->vatRate());
+
+ $user->setSetting('country', 'PL');
+ $this->assertSame(null, $wallet->vatRate());
+
+ $user->setSetting('country', 'US');
+ $this->assertSame($rate1->id, $wallet->vatRate()->id); // @phpstan-ignore-line
+
+ $user->setSetting('country', 'DE');
+ $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line
+
+ // Test $start argument
+ $rate3 = VatRate::create([
+ 'start' => now()->subYear(),
+ 'country' => 'DE',
+ 'rate' => 5.0,
+ ]);
+
+ $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line
+ $this->assertSame($rate3->id, $wallet->vatRate(now()->subMonth())->id);
+ $this->assertSame(null, $wallet->vatRate(now()->subYears(2)));
+ }
}
diff --git a/src/tests/Unit/Backends/DAV/VcardTest.php b/src/tests/Unit/Backends/DAV/VcardTest.php
--- a/src/tests/Unit/Backends/DAV/VcardTest.php
+++ b/src/tests/Unit/Backends/DAV/VcardTest.php
@@ -27,7 +27,7 @@
N:;;;;
UID:$uid
END:VCARD
-]]></c:calendar-data>
+]]></c:address-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>

File Metadata

Mime Type
text/plain
Expires
Mon, Apr 6, 9:09 AM (11 h, 2 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18836337
Default Alt Text
D4076.1775466546.diff (79 KB)

Event Timeline