Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2534004
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
39 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/app/Console/Commands/Wallet/ChargeCommand.php b/src/app/Console/Commands/Wallet/ChargeCommand.php
index b5a96d0c..7a0ff711 100644
--- a/src/app/Console/Commands/Wallet/ChargeCommand.php
+++ b/src/app/Console/Commands/Wallet/ChargeCommand.php
@@ -1,73 +1,83 @@
<?php
namespace App\Console\Commands\Wallet;
use App\Console\Command;
class ChargeCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'wallet:charge {wallet?}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Charge wallets';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if ($wallet = $this->argument('wallet')) {
// Find specified wallet by ID
$wallet = $this->getWallet($wallet);
if (!$wallet) {
$this->error("Wallet not found.");
return 1;
}
if (!$wallet->owner) {
$this->error("Wallet's owner is deleted.");
return 1;
}
$wallets = [$wallet];
} else {
// Get all wallets, excluding deleted accounts
- $wallets = \App\Wallet::select('wallets.*')
+ $wallets = \App\Wallet::select('wallets.id')
->join('users', 'users.id', '=', 'wallets.user_id')
->withEnvTenantContext('users')
->whereNull('users.deleted_at')
->cursor();
}
foreach ($wallets as $wallet) {
+ // This is a long-running process. Because another process might have modified
+ // the wallet balance in meantime we have to refresh it.
+ // Note: This is needed despite the use of cursor() above.
+ $wallet->refresh();
+
+ // Sanity check after refresh (owner deleted in meantime)
+ if (!$wallet->owner) {
+ continue;
+ }
+
$charge = $wallet->chargeEntitlements();
if ($charge > 0) {
$this->info(
"Charged wallet {$wallet->id} for user {$wallet->owner->email} with {$charge}"
);
// Top-up the wallet if auto-payment enabled for the wallet
\App\Jobs\WalletCharge::dispatch($wallet);
}
if ($wallet->balance < 0) {
// Check the account balance, send notifications, (suspend, delete,) degrade
// Also sends reminders to the degraded account owners
\App\Jobs\WalletCheck::dispatch($wallet);
}
}
}
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
index 02ea6c0b..d69fd7df 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,330 +1,330 @@
<?php
namespace App\Providers;
use App\Payment;
use App\Transaction;
use App\Wallet;
use Illuminate\Support\Facades\Cache;
abstract class PaymentProvider
{
public const METHOD_CREDITCARD = 'creditcard';
public const METHOD_PAYPAL = 'paypal';
public const METHOD_BANKTRANSFER = 'banktransfer';
public const METHOD_DIRECTDEBIT = 'directdebit';
public const METHOD_BITCOIN = 'bitcoin';
public const PROVIDER_MOLLIE = 'mollie';
public const PROVIDER_STRIPE = 'stripe';
public const PROVIDER_COINBASE = 'coinbase';
private static $paymentMethodIcons = [
self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'],
self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'],
self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'building-columns'],
self::METHOD_BITCOIN => ['prefix' => 'fab', 'name' => 'bitcoin'],
];
/**
* Detect the name of the provider
*
* @param \App\Wallet|string|null $provider_or_wallet
* @return string The name of the provider
*/
private static function providerName($provider_or_wallet = null): string
{
if ($provider_or_wallet instanceof Wallet) {
$settings = $provider_or_wallet->getSettings(['stripe_id', 'mollie_id']);
if ($settings['stripe_id']) {
$provider = self::PROVIDER_STRIPE;
} elseif ($settings['mollie_id']) {
$provider = self::PROVIDER_MOLLIE;
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
$provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE;
}
return \strtolower($provider);
}
/**
* Factory method
*
* @param \App\Wallet|string|null $provider_or_wallet
*/
public static function factory($provider_or_wallet = null, $currency = null)
{
if (\strtolower($currency) == 'btc') {
return new \App\Providers\Payment\Coinbase();
}
switch (self::providerName($provider_or_wallet)) {
case self::PROVIDER_STRIPE:
return new \App\Providers\Payment\Stripe();
case self::PROVIDER_MOLLIE:
return new \App\Providers\Payment\Mollie();
case self::PROVIDER_COINBASE:
return new \App\Providers\Payment\Coinbase();
default:
throw new \Exception("Invalid payment provider: {$provider_or_wallet}");
}
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - 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
* - redirectUrl: The location to goto after checkout
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
abstract public function createMandate(Wallet $wallet, array $payment): ?array;
/**
* Revoke the auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
abstract public function deleteMandate(Wallet $wallet): bool;
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
abstract public function getMandate(Wallet $wallet): ?array;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
abstract public function customerLink(Wallet $wallet): ?string;
/**
* Get a provider name
*
* @return string Provider name
*/
abstract public function name(): string;
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - 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
* - methodId: Payment method
*
* @return array Provider payment/session data:
* - id: Operation identifier
* - redirectUrl
*/
abstract public function payment(Wallet $wallet, array $payment): ?array;
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
abstract public function webhook(): int;
/**
* Create a payment record in DB
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
*
* @return \App\Payment Payment object
*/
protected function storePayment(array $payment, $wallet_id): Payment
{
$payment['wallet_id'] = $wallet_id;
$payment['provider'] = $this->name();
return Payment::createFromArray($payment);
}
/**
* Convert a value from $sourceCurrency to $targetCurrency
*
* @param int $amount Amount in cents of $sourceCurrency
* @param string $sourceCurrency Currency from which to convert
* @param string $targetCurrency Currency to convert to
*
* @return int Exchanged amount in cents of $targetCurrency
*/
protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int
{
return intval(round($amount * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency)));
}
/**
* List supported payment methods from this provider
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
abstract public function providerPaymentMethods(string $type, string $currency): array;
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
abstract public function getPayment($paymentId): array;
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
protected static function paymentMethodsWhitelist($type): array
{
$methods = [];
switch ($type) {
case Payment::TYPE_ONEOFF:
$methods = explode(',', \config('app.payment.methods_oneoff'));
break;
case Payment::TYPE_RECURRING:
$methods = explode(',', \config('app.payment.methods_recurring'));
break;
default:
\Log::error("Unknown payment type: " . $type);
}
$methods = array_map('strtolower', array_map('trim', $methods));
return $methods;
}
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
private static function applyMethodWhitelist($type, $availableMethods): array
{
$methods = [];
// Use only whitelisted methods, and apply values from whitelist (overriding the backend)
$whitelistMethods = self::paymentMethodsWhitelist($type);
foreach ($whitelistMethods as $id) {
if (array_key_exists($id, $availableMethods)) {
$method = $availableMethods[$id];
$method['icon'] = self::$paymentMethodIcons[$id];
$methods[] = $method;
}
}
return $methods;
}
/**
* List supported payment methods for $wallet
*
* @param \App\Wallet $wallet The wallet
* @param string $type The payment type for which we require a method (oneoff/recurring).
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public static function paymentMethods(Wallet $wallet, $type): array
{
$providerName = self::providerName($wallet);
$cacheKey = "methods-{$providerName}-{$type}-{$wallet->currency}";
if ($methods = Cache::get($cacheKey)) {
\Log::debug("Using payment method cache" . var_export($methods, true));
return $methods;
}
$provider = PaymentProvider::factory($providerName);
$methods = $provider->providerPaymentMethods($type, $wallet->currency);
if (!empty(\config('services.coinbase.key'))) {
$coinbaseProvider = PaymentProvider::factory(self::PROVIDER_COINBASE);
$methods = array_merge($methods, $coinbaseProvider->providerPaymentMethods($type, $wallet->currency));
}
$methods = self::applyMethodWhitelist($type, $methods);
- \Log::debug("Loaded payment methods" . var_export($methods, true));
+ \Log::debug("Loaded payment methods " . var_export($methods, true));
Cache::put($cacheKey, $methods, now()->addHours(1));
return $methods;
}
/**
* Returns the full URL for the wallet page, used when returning from an external payment page.
* Depending on the request origin it will return a URL for the User or Reseller UI.
*
* @return string The redirect URL
*/
public static function redirectUrl(): string
{
$url = \App\Utils::serviceUrl('/wallet');
$domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
if (strpos($domain, 'reseller') === 0) {
$url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url);
}
return $url;
}
}
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
index 40d08fdf..7c627a3d 100644
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -1,671 +1,692 @@
<?php
namespace Tests\Feature;
use App\Discount;
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;
use Tests\TestCase;
class WalletTest extends TestCase
{
private $users = [
'UserWallet1@UserWallet.com',
'UserWallet2@UserWallet.com',
'UserWallet3@UserWallet.com',
'UserWallet4@UserWallet.com',
'UserWallet5@UserWallet.com',
'WalletControllerA@WalletController.com',
'WalletControllerB@WalletController.com',
'WalletController2A@WalletController.com',
'WalletController2B@WalletController.com',
'jane@kolabnow.com'
];
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::createFromDate(2022, 02, 02));
foreach ($this->users as $user) {
$this->deleteTestUser($user);
}
Sku::select()->update(['fee' => 0]);
Payment::query()->delete();
VatRate::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
foreach ($this->users as $user) {
$this->deleteTestUser($user);
}
Sku::select()->update(['fee' => 0]);
Payment::query()->delete();
VatRate::query()->delete();
Plan::withEnvTenantContext()->where('title', 'individual')->update(['months' => 1]);
parent::tearDown();
}
/**
* Test that turning wallet balance from negative to positive
* unsuspends and undegrades the account
*/
public function testBalanceTurnsPositive(): void
{
Queue::fake();
$user = $this->getTestUser('UserWallet1@UserWallet.com');
$user->suspend();
$user->degrade();
$wallet = $user->wallets()->first();
$wallet->balance = -100;
$wallet->save();
$this->assertTrue($user->isSuspended());
$this->assertTrue($user->isDegraded());
$this->assertNotNull($wallet->getSetting('balance_negative_since'));
$wallet->balance = 100;
$wallet->save();
$user->refresh();
$this->assertFalse($user->isSuspended());
$this->assertFalse($user->isDegraded());
$this->assertNull($wallet->getSetting('balance_negative_since'));
// Test un-restricting users on balance change
$this->deleteTestUser('UserWallet1@UserWallet.com');
$owner = $this->getTestUser('UserWallet1@UserWallet.com');
$user1 = $this->getTestUser('UserWallet2@UserWallet.com');
$user2 = $this->getTestUser('UserWallet3@UserWallet.com');
$package = Package::withEnvTenantContext()->where('title', 'lite')->first();
$owner->assignPackage($package, $user1);
$owner->assignPackage($package, $user2);
$wallet = $owner->wallets()->first();
$owner->restrict();
$user1->restrict();
$user2->restrict();
$this->assertTrue($owner->isRestricted());
$this->assertTrue($user1->isRestricted());
$this->assertTrue($user2->isRestricted());
Queue::fake();
$wallet->balance = 100;
$wallet->save();
$this->assertFalse($owner->fresh()->isRestricted());
$this->assertFalse($user1->fresh()->isRestricted());
$this->assertFalse($user2->fresh()->isRestricted());
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// TODO: Test group account and unsuspending domain/members/groups
}
/**
* Test for Wallet::balanceLastsUntil()
*/
public function testBalanceLastsUntil(): void
{
// Monthly cost of all entitlements: 990
// 28 days: 35.36 per day
// 31 days: 31.93 per day
$user = $this->getTestUser('jane@kolabnow.com');
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$user->assignPlan($plan);
$wallet = $user->wallets()->first();
// User/entitlements created today, balance=0
$until = $wallet->balanceLastsUntil();
$this->assertSame(
Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(),
$until->toDateString()
);
// User/entitlements created today, balance=-10 CHF
$wallet->balance = -1000;
$until = $wallet->balanceLastsUntil();
$this->assertSame(null, $until);
// User/entitlements created today, balance=-9,99 CHF (monthly cost)
$wallet->balance = 990;
$until = $wallet->balanceLastsUntil();
$daysInLastMonth = \App\Utils::daysInLastMonth();
$delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days;
$this->assertTrue($delta <= 1);
$this->assertTrue($delta >= -1);
// Old entitlements, 100% discount
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40));
$discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first();
$wallet->discount()->associate($discount);
$until = $wallet->refresh()->balanceLastsUntil();
$this->assertSame(null, $until);
// User with no entitlements
$wallet->discount()->dissociate($discount);
$wallet->entitlements()->delete();
$until = $wallet->refresh()->balanceLastsUntil();
$this->assertSame(null, $until);
}
/**
* Verify a wallet is created, when a user is created.
*/
public function testCreateUserCreatesWallet(): void
{
$user = $this->getTestUser('UserWallet1@UserWallet.com');
$this->assertCount(1, $user->wallets);
$this->assertSame(\config('app.currency'), $user->wallets[0]->currency);
$this->assertSame(0, $user->wallets[0]->balance);
}
/**
* Verify a user can haz more wallets.
*/
public function testAddWallet(): void
{
$user = $this->getTestUser('UserWallet2@UserWallet.com');
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
$this->assertCount(2, $user->wallets);
$user->wallets()->each(
function ($wallet) {
$this->assertEquals(0, $wallet->balance);
}
);
// For now all wallets use system currency
$this->assertFalse($user->wallets()->where('currency', 'USD')->exists());
}
/**
* Verify we can not delete a user wallet that holds balance.
*/
public function testDeleteWalletWithCredit(): void
{
$user = $this->getTestUser('UserWallet3@UserWallet.com');
$user->wallets()->each(
function ($wallet) {
$wallet->credit(100)->save();
}
);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
* Verify we can not delete a wallet that is the last wallet.
*/
public function testDeleteLastWallet(): void
{
$user = $this->getTestUser('UserWallet4@UserWallet.com');
$this->assertCount(1, $user->wallets);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
* Verify we can remove a wallet that is an additional wallet.
*/
public function testDeleteAddtWallet(): void
{
$user = $this->getTestUser('UserWallet5@UserWallet.com');
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
// For now additional wallets with a different currency is not allowed
$this->assertFalse($user->wallets()->where('currency', 'USD')->exists());
/*
$user->wallets()->each(
function ($wallet) {
if ($wallet->currency == 'USD') {
$this->assertNotFalse($wallet->delete());
}
}
);
*/
}
/**
* Verify a wallet can be assigned a controller.
*/
public function testAddController(): void
{
$userA = $this->getTestUser('WalletControllerA@WalletController.com');
$userB = $this->getTestUser('WalletControllerB@WalletController.com');
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertCount(1, $userB->accounts);
$aWallet = $userA->wallets()->first();
$bAccount = $userB->accounts()->first();
$this->assertTrue($bAccount->id === $aWallet->id);
}
/**
* Test Wallet::getMinMandateAmount()
*/
public function testGetMinMandateAmount(): void
{
$user = $this->getTestUser('WalletControllerA@WalletController.com');
$user->setSetting('plan_id', null);
$wallet = $user->wallets()->first();
// No plan assigned
$this->assertSame(Payment::MIN_AMOUNT, $wallet->getMinMandateAmount());
// Plan assigned
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$plan->months = 12;
$plan->save();
$user->setSetting('plan_id', $plan->id);
$this->assertSame(990 * 12, $wallet->getMinMandateAmount());
// Plan and discount
$discount = Discount::where('discount', 30)->first();
$wallet->discount()->associate($discount);
$wallet->save();
$this->assertSame((int) (990 * 12 * 0.70), $wallet->getMinMandateAmount());
}
/**
* Test Wallet::isController()
*/
public function testIsController(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $jack->wallet();
$this->assertTrue($wallet->isController($john));
$this->assertTrue($wallet->isController($ned));
$this->assertFalse($wallet->isController($jack));
}
/**
* Verify controllers can also be removed from wallets.
*/
public function testRemoveWalletController(): void
{
$userA = $this->getTestUser('WalletController2A@WalletController.com');
$userB = $this->getTestUser('WalletController2B@WalletController.com');
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$userB->refresh();
$userB->accounts()->each(
function ($wallet) use ($userB) {
$wallet->removeController($userB);
}
);
$this->assertCount(0, $userB->accounts);
}
/**
* Test for charging and removing entitlements (including tenant commission calculations)
*/
public function testChargeAndDeleteEntitlements(): void
{
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
$discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
$wallet->discount()->associate($discount);
$wallet->save();
// Add 40% fee to all SKUs
Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]);
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPlan($plan);
$user->assignSku($storage, 5);
$user->setSetting('plan_id', null); // disable plan and trial
// Reset reseller's wallet balance and transactions
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
// ------------------------------------
// Test normal charging of entitlements
// ------------------------------------
// Backdate and charge entitlements, we're expecting one month to be charged
// Set fake NOW date to make simpler asserting results that depend on number of days in current/last month
Carbon::setTestNow(Carbon::create(2021, 5, 21, 12));
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
$reseller_wallet->refresh();
// User discount is 30%
// Expected: groupware: 490 x 70% + mailbox: 500 x 70% + storage: 5 x round(25x70%) = 778
$this->assertSame(-778, $wallet->balance);
// Reseller fee is 40%
// Expected: groupware: 490 x 30% + mailbox: 500 x 30% + storage: 5 x round(25x30%) = 332
$this->assertSame(332, $reseller_wallet->balance);
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(332, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$this->assertCount(1, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-778, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// Assert all entitlements' updated_at timestamp
$date = $backdate->addMonthsWithoutOverflow(1);
$this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
// -----------------------------------
// Test charging on entitlement delete
// -----------------------------------
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->delete();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->delete();
$user->removeSku($storage, 2);
// we expect the wallet to have been charged for 19 days of use of
// 2 deleted storage entitlements
$wallet->refresh();
$reseller_wallet->refresh();
// 2 x round(25 / 31 * 19 * 0.7) = 22
$this->assertSame(-(778 + 22), $wallet->balance);
// 22 - 2 x round(25 * 0.4 / 31 * 19) = 10
$this->assertSame(10, $reseller_wallet->balance);
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertCount(2, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(5, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$trans = $reseller_transactions[1];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(5, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$this->assertCount(2, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-11, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
$trans = $transactions[1];
$this->assertSame('', $trans->description);
$this->assertSame(-11, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// TODO: Test entitlement transaction records
}
/**
* Test for charging and removing entitlements when in trial
*/
public function testChargeAndDeleteEntitlementsTrial(): void
{
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPlan($plan);
$user->assignSku($storage, 5);
// ------------------------------------
// Test normal charging of entitlements
// ------------------------------------
// Backdate and charge entitlements, we're expecting one month to be charged
// Set fake NOW date to make simpler asserting results that depend on number of days in current/last month
Carbon::setTestNow(Carbon::create(2021, 5, 21, 12));
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
// Expected: storage: 5 x 25 = 125 (the rest is free in trial)
$this->assertSame($balance = -125, $wallet->balance);
// Assert wallet transaction
$transactions = $wallet->transactions()->get();
$this->assertCount(1, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame($balance, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// Assert entitlement transactions
$etransactions = Transaction::where('transaction_id', $trans->id)->get();
$this->assertCount(5, $etransactions);
$trans = $etransactions[0];
$this->assertSame(null, $trans->description);
$this->assertSame(25, $trans->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type);
// Assert all entitlements' updated_at timestamp
$date = $backdate->addMonthsWithoutOverflow(1);
$this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
// Run again, expect no changes
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
$this->assertSame($balance, $wallet->balance);
$this->assertCount(1, $wallet->transactions()->get());
$this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
// -----------------------------------
// Test charging on entitlement delete
// -----------------------------------
$wallet->transactions()->delete();
$user->removeSku($storage, 2);
$wallet->refresh();
// we expect the wallet to have been charged for 19 days of use of
// 2 deleted storage entitlements: 2 x round(25 / 31 * 19) = 30
$this->assertSame($balance -= 30, $wallet->balance);
// Assert wallet transactions
$transactions = $wallet->transactions()->get();
$this->assertCount(2, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-15, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
$trans = $transactions[1];
$this->assertSame('', $trans->description);
$this->assertSame(-15, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// Assert entitlement transactions
/* Note: Commented out because the observer does not create per-entitlement transactions
$etransactions = Transaction::where('transaction_id', $transactions[0]->id)->get();
$this->assertCount(1, $etransactions);
$trans = $etransactions[0];
$this->assertSame(null, $trans->description);
$this->assertSame(15, $trans->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type);
$etransactions = Transaction::where('transaction_id', $transactions[1]->id)->get();
$this->assertCount(1, $etransactions);
$trans = $etransactions[0];
$this->assertSame(null, $trans->description);
$this->assertSame(15, $trans->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type);
*/
}
/**
* Tests for award() and penalty()
*/
public function testAwardAndPenalty(): void
{
- $this->markTestIncomplete();
+ $user = $this->getTestUser('UserWallet1@UserWallet.com');
+ $wallet = $user->wallets()->first();
+
+ // Test award
+ $this->assertSame($wallet->id, $wallet->award(100, 'test')->id);
+ $this->assertSame(100, $wallet->balance);
+ $this->assertSame(100, $wallet->fresh()->balance);
+ $transaction = $wallet->transactions()->first();
+ $this->assertSame(100, $transaction->amount);
+ $this->assertSame(Transaction::WALLET_AWARD, $transaction->type);
+ $this->assertSame('test', $transaction->description);
+
+ $wallet->transactions()->delete();
+
+ // Test penalty
+ $this->assertSame($wallet->id, $wallet->penalty(100, 'test')->id);
+ $this->assertSame(0, $wallet->balance);
+ $this->assertSame(0, $wallet->fresh()->balance);
+ $transaction = $wallet->transactions()->first();
+ $this->assertSame(-100, $transaction->amount);
+ $this->assertSame(Transaction::WALLET_PENALTY, $transaction->type);
+ $this->assertSame('test', $transaction->description);
}
/**
* 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)));
}
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Fri, Feb 6, 5:46 AM (9 h, 13 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
428114
Default Alt Text
(39 KB)
Attached To
Mode
R2 kolab
Attached
Detach File
Event Timeline
Log In to Comment