Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions src/Model/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ class User extends Entity
public const ROLE_USER = 'user';
public const ROLE_SERVICE = 'service';

public const SPENDING_LIMIT_90_DAYS = 500;

/**
* @param array<string, bool>|null $notifications The user's notification settings.
* @return array<string, bool>
Expand Down Expand Up @@ -209,24 +211,46 @@ public function potatoResetInMinutes(): string
*/
public function spendablePotato(): int
{
$pruchasesTable = $this->fetchTable('Purchases');
$purchasesTable = $this->fetchTable('Purchases');

$query = $pruchasesTable->find();
$result = $query
// Calculate total spent by this user (all time)
$totalSpentQuery = $purchasesTable->find();
$totalSpentResult = $totalSpentQuery
->select([
'spent' => $query->func()->sum('price'),
'spent' => $totalSpentQuery->func()->sum('price'),
])
->where([
'user_id' => $this->id,
])
->first();
$totalSpent = (int)$totalSpentResult->spent;

// @FIXME make this based on 90 days
if ((int)$result->spent >= 500) {
// Calculate spent in the last 90 days
$ninetyDaysAgo = DateTime::now()->subDays(90);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Timezone Mismatch in 90-Day Calculation

The spendablePotato method calculates the 90-day spending window using DateTime::now()->subDays(90). This implicitly uses the server's default timezone, which can cause incorrect 90-day period calculations against UTC database timestamps. Other methods in this class explicitly handle timezones, making this an inconsistency.

Fix in Cursor Fix in Web

$last90DaysQuery = $purchasesTable->find();
$last90DaysResult = $last90DaysQuery
->select([
'spent' => $last90DaysQuery->func()->sum('price'),
])
->where([
'user_id' => $this->id,
'created >=' => $ninetyDaysAgo,
])
->first();
$spentLast90Days = (int)$last90DaysResult->spent;

// Check if the user has reached the spending limit in the last 90 days
if ($spentLast90Days >= self::SPENDING_LIMIT_90_DAYS) {
return 0;
}

return $this->potatoReceived() - (int)$result->spent;
// Calculate available balance (total received minus total spent)
$availableBalance = $this->potatoReceived() - $totalSpent;

// Return the minimum of available balance or remaining 90-day limit
$remainingLimit = self::SPENDING_LIMIT_90_DAYS - $spentLast90Days;

return min($availableBalance, $remainingLimit);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Negative Spendable Balance Error

The spendablePotato() method can return negative values when a user's total spent potatoes exceed their total received, making availableBalance negative. Conceptually, a "spendable" amount should not be negative, as it implies a user can spend negative potatoes. This occurs in the min($availableBalance, $remainingLimit) calculation.

Fix in Cursor Fix in Web

}

/**
Expand Down
83 changes: 83 additions & 0 deletions tests/Fixture/PurchasesFixture.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);

namespace App\Test\Fixture;

use Cake\TestSuite\Fixture\TestFixture;

/**
* PurchasesFixture
*/
class PurchasesFixture extends TestFixture
{
/**
* Records
*
* @var array<array<string, mixed>>
*/
public array $records = [
// User 1 purchases
[
'id' => 1,
'user_id' => '00000000-0000-0000-0000-000000000001',
'presentee_id' => null,
'name' => 'Old Purchase',
'description' => 'Purchase from 120 days ago',
'image_link' => 'https://example.com/old.jpg',
'message' => null,
'price' => 100,
'code' => null,
'created' => '2025-05-15 10:00:00', // ~120 days ago from Sept 12, 2025
],
[
'id' => 2,
'user_id' => '00000000-0000-0000-0000-000000000001',
'presentee_id' => null,
'name' => 'Recent Purchase 1',
'description' => 'Purchase from 30 days ago',
'image_link' => 'https://example.com/recent1.jpg',
'message' => null,
'price' => 200,
'code' => null,
'created' => '2025-08-13 10:00:00', // ~30 days ago
],
[
'id' => 3,
'user_id' => '00000000-0000-0000-0000-000000000001',
'presentee_id' => null,
'name' => 'Recent Purchase 2',
'description' => 'Purchase from 10 days ago',
'image_link' => 'https://example.com/recent2.jpg',
'message' => null,
'price' => 150,
'code' => null,
'created' => '2025-09-02 10:00:00', // ~10 days ago
],
// User 2 purchases (at spending limit)
[
'id' => 4,
'user_id' => '00000000-0000-0000-0000-000000000002',
'presentee_id' => null,
'name' => 'Max Purchase 1',
'description' => 'Purchase from 60 days ago',
'image_link' => 'https://example.com/max1.jpg',
'message' => null,
'price' => 300,
'code' => null,
'created' => '2025-07-14 10:00:00', // ~60 days ago
],
[
'id' => 5,
'user_id' => '00000000-0000-0000-0000-000000000002',
'presentee_id' => null,
'name' => 'Max Purchase 2',
'description' => 'Purchase from 20 days ago',
'image_link' => 'https://example.com/max2.jpg',
'message' => null,
'price' => 200,
'code' => null,
'created' => '2025-08-23 10:00:00', // ~20 days ago
],
// User 3 has no purchases
];
}
164 changes: 164 additions & 0 deletions tests/TestCase/Model/Entity/UserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@

namespace App\Test\TestCase\Model\Entity;

use App\Model\Entity\Message;
use Cake\Chronos\Chronos;
use Cake\TestSuite\TestCase;
use Cake\Utility\Text;

/**
* App\Model\Entity\User Test Case
Expand All @@ -18,6 +20,8 @@ class UserTest extends TestCase
*/
protected array $fixtures = [
'app.Users',
'app.Messages',
'app.Purchases',
];

/**
Expand Down Expand Up @@ -121,4 +125,164 @@ public function testPotatoResetInHours(): void
$this->assertSame('15', $this->UserCanada->potatoResetInHours());
$this->assertSame('18', $this->UserUS->potatoResetInHours());
}

/**
* Test spendablePotato method with no purchases and no received potatoes
*
* @return void
* @uses \App\Model\Entity\User::spendablePotato()
*/
public function testSpendablePotatoNoPurchasesNoReceived(): void
{
// User 3 has no purchases and no received potatoes
$spendable = $this->UserUS->spendablePotato();
$this->assertSame(0, $spendable);
}

/**
* Test spendablePotato method with recent purchases within 90-day limit
*
* @return void
* @uses \App\Model\Entity\User::spendablePotato()
*/
public function testSpendablePotatoWithRecentPurchases(): void
{
Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC'));

// Add some received potatoes for User 1
$messagesTable = $this->fetchTable('Messages');
$message = $messagesTable->newEntity([
'id' => Text::uuid(),
'sender_user_id' => $this->UserCanada->id,
'receiver_user_id' => $this->UserEurope->id,
'amount' => 600,
'type' => Message::TYPE_POTATO,
'created' => new Chronos('2025-08-01 10:00:00'),
], ['accessibleFields' => ['*' => true]]);
$messagesTable->saveOrFail($message);

// User 1 has received 600 potatoes
// User 1 has spent 100 (>90 days ago) + 200 (30 days ago) + 150 (10 days ago) = 450 total
// Only 200 + 150 = 350 in last 90 days
// Available balance: 600 - 450 = 150
// Remaining 90-day limit: 500 - 350 = 150
// Should return min(150, 150) = 150
$spendable = $this->UserEurope->spendablePotato();
$this->assertSame(150, $spendable);
}

/**
* Test spendablePotato method when user has reached 90-day spending limit
*
* @return void
* @uses \App\Model\Entity\User::spendablePotato()
*/
public function testSpendablePotatoAtSpendingLimit(): void
{
Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC'));

// Add some received potatoes for User 2
$messagesTable = $this->fetchTable('Messages');
$message = $messagesTable->newEntity([
'id' => Text::uuid(),
'sender_user_id' => $this->UserEurope->id,
'receiver_user_id' => $this->UserCanada->id,
'amount' => 800,
'type' => Message::TYPE_POTATO,
'created' => new Chronos('2025-08-01 10:00:00'),
], ['accessibleFields' => ['*' => true]]);
$messagesTable->saveOrFail($message);

// User 2 has received 800 potatoes
// User 2 has spent 300 (60 days ago) + 200 (20 days ago) = 500 in last 90 days
// Has reached the 500 limit
$spendable = $this->UserCanada->spendablePotato();
$this->assertSame(0, $spendable);
}

/**
* Test spendablePotato method with old purchases outside 90-day window
*
* @return void
* @uses \App\Model\Entity\User::spendablePotato()
*/
public function testSpendablePotatoWithOldPurchases(): void
{
Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC'));

// Create a user with only old purchases
$purchasesTable = $this->fetchTable('Purchases');
$purchase = $purchasesTable->newEntity([
'user_id' => $this->UserUS->id,
'name' => 'Very Old Purchase',
'description' => 'Purchase from 100 days ago',
'image_link' => 'https://example.com/old.jpg',
'price' => 400,
'created' => new Chronos('2025-06-04 10:00:00'), // > 90 days ago
], ['accessibleFields' => ['*' => true]]);
$purchasesTable->saveOrFail($purchase);

// Add received potatoes
$messagesTable = $this->fetchTable('Messages');
$message = $messagesTable->newEntity([
'id' => Text::uuid(),
'sender_user_id' => $this->UserEurope->id,
'receiver_user_id' => $this->UserUS->id,
'amount' => 600,
'type' => Message::TYPE_POTATO,
'created' => new Chronos('2025-08-01 10:00:00'),
], ['accessibleFields' => ['*' => true]]);
$messagesTable->saveOrFail($message);

// User 3 has received 600 potatoes
// User 3 has spent 400 total, but 0 in last 90 days
// Available balance: 600 - 400 = 200
// Remaining 90-day limit: 500 - 0 = 500
// Should return min(200, 500) = 200
$spendable = $this->UserUS->spendablePotato();
$this->assertSame(200, $spendable);
}

/**
* Test spendablePotato method when balance is less than 90-day limit
*
* @return void
* @uses \App\Model\Entity\User::spendablePotato()
*/
public function testSpendablePotatoLimitedByBalance(): void
{
Chronos::setTestNow(new Chronos('2025-09-12 12:00:00', 'UTC'));

// Create a scenario where available balance is less than 90-day limit
$purchasesTable = $this->fetchTable('Purchases');
$purchase = $purchasesTable->newEntity([
'user_id' => $this->UserUS->id,
'name' => 'Recent Small Purchase',
'description' => 'Small purchase from 5 days ago',
'image_link' => 'https://example.com/small.jpg',
'price' => 50,
'created' => new Chronos('2025-09-07 10:00:00'), // 5 days ago
], ['accessibleFields' => ['*' => true]]);
$purchasesTable->saveOrFail($purchase);

// Add limited received potatoes
$messagesTable = $this->fetchTable('Messages');
$message = $messagesTable->newEntity([
'id' => Text::uuid(),
'sender_user_id' => $this->UserCanada->id,
'receiver_user_id' => $this->UserUS->id,
'amount' => 100,
'type' => Message::TYPE_POTATO,
'created' => new Chronos('2025-08-01 10:00:00'),
], ['accessibleFields' => ['*' => true]]);
$messagesTable->saveOrFail($message);

// User 3 has received 100 potatoes
// User 3 has spent 50 in last 90 days
// Available balance: 100 - 50 = 50
// Remaining 90-day limit: 500 - 50 = 450
// Should return min(50, 450) = 50 (limited by balance)
$spendable = $this->UserUS->spendablePotato();
$this->assertSame(50, $spendable);
}
}
Loading