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 @@ +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 @@ +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 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 The attributes that should be cast */ protected $casts = [ - 'amount' => 'integer' + 'amount' => 'integer', + 'credit_amount' => 'integer', + 'currency_amount' => 'integer', ]; /** @var array 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 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); } /** @@ -214,53 +209,6 @@ } /** - * 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 * * @param string $type The payment type for which we require a method (oneoff/recurring). 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 @@ + The attributes that should be cast */ + protected $casts = [ + 'start' => 'datetime:Y-m-d H:i:s', + 'rate' => 'float' + ]; + + /** @var array The attributes that are mass assignable */ + protected $fillable = [ + 'country', + 'rate', + 'start', + ]; + + /** @var bool Indicates if the model should be timestamped. */ + public $timestamps = false; +} diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -67,6 +67,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 * * @param bool $apply Set to false for a dry-run mode @@ -230,6 +333,19 @@ } /** + * 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. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany @@ -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); } /** @@ -411,6 +491,19 @@ } /** + * 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. * * @return ?\App\Plan @@ -437,6 +530,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. * * @return \Illuminate\Database\Eloquent\Builder Query builder @@ -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 @@ +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 @@ -697,6 +704,70 @@ } /** + * 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 * * @group mollie @@ -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 @@ +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(); } @@ -558,10 +564,77 @@ } /** + * 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() */ public function testUpdateEntitlements(): void { $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 -]]> +]]> HTTP/1.1 200 OK