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
41 changes: 41 additions & 0 deletions app-modules/economy/database/factories/ShopListingFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace He4rt\Economy\Database\Factories;

use He4rt\Economy\Shop\Models\ShopListing;
use He4rt\Gamification\Item\Models\Item;
use He4rt\Identity\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<ShopListing>
*/
final class ShopListingFactory extends Factory
{
protected $model = ShopListing::class;

public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'item_id' => Item::factory(),
'price' => fake()->numberBetween(10, 500),
'stock' => null,
'available_from' => null,
'available_until' => null,
'active' => true,
];
}

public function limited(int $stock = 10): static
{
return $this->state(['stock' => $stock]);
}

public function inactive(): static
{
return $this->state(['active' => false]);
}
}
54 changes: 54 additions & 0 deletions app-modules/economy/database/factories/TradeFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace He4rt\Economy\Database\Factories;

use He4rt\Economy\Trade\Enums\TradeStatus;
use He4rt\Economy\Trade\Models\Trade;
use He4rt\Gamification\Character\Models\Character;
use He4rt\Identity\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<Trade>
*/
final class TradeFactory extends Factory
{
protected $model = Trade::class;

public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'initiator_character_id' => Character::factory(),
'receiver_character_id' => Character::factory(),
'status' => TradeStatus::Pending,
'resolved_at' => null,
];
}

public function accepted(): static
{
return $this->state([
'status' => TradeStatus::Accepted,
'resolved_at' => now(),
]);
}

public function rejected(): static
{
return $this->state([
'status' => TradeStatus::Rejected,
'resolved_at' => now(),
]);
}

public function cancelled(): static
{
return $this->state([
'status' => TradeStatus::Cancelled,
'resolved_at' => now(),
]);
}
}
33 changes: 33 additions & 0 deletions app-modules/economy/database/factories/TradeItemFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace He4rt\Economy\Database\Factories;

use He4rt\Economy\Trade\Enums\TradeDirection;
use He4rt\Economy\Trade\Models\Trade;
use He4rt\Economy\Trade\Models\TradeItem;
use He4rt\Gamification\Character\Inventory\Models\CharacterItem;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
* @extends Factory<TradeItem>
*/
final class TradeItemFactory extends Factory
{
protected $model = TradeItem::class;

public function definition(): array
{
return [
'trade_id' => Trade::factory(),
'character_item_id' => CharacterItem::factory(),
'direction' => TradeDirection::Offer,
];
}

public function asRequest(): static
{
return $this->state(['direction' => TradeDirection::Request]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('shop_listings', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify nullable + nullOnDelete consistency for tenant_id FK in this migration.
rg -n "foreignId\('tenant_id'\).*nullOnDelete\(\)" app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php
rg -n "foreignId\('tenant_id'\).*nullable\(\).*nullOnDelete\(\)|foreignId\('tenant_id'\).*nullOnDelete\(\).*nullable\(\)" app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php

Repository: he4rt/heartdevs.com

Length of output: 149


🏁 Script executed:

cat app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php

Repository: he4rt/heartdevs.com

Length of output: 1070


Make tenant_id nullable when using nullOnDelete().

The column is configured with nullOnDelete() but is non-nullable. When a tenant is deleted, the database constraint will fail because it cannot set the column to NULL. This causes referential integrity violations.

🔧 Proposed fix
-            $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete();
+            $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete();
$table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php`
at line 15, The migration sets a foreignId 'tenant_id' with nullOnDelete() but
the column isn't nullable; update the migration so the tenant_id column is
nullable (e.g., call nullable() on the column before constrained()) so the
foreign key can be set to NULL on tenant deletion; locate the line with
$table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete() in the
CreateShopListings migration and add nullable() to that chain to fix the
constraint.

$table->foreignUuid('item_id')->constrained('items');
$table->integer('price');
$table->integer('stock')->nullable();
$table->timestamp('available_from')->nullable();
$table->timestamp('available_until')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();

$table->index(['tenant_id', 'active']);
$table->index('item_id');
});
}

public function down(): void
{
Schema::dropIfExists('shop_listings');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('trades', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n app-modules/economy/database/migrations/2026_03_25_100002_create_trades_table.php

Repository: he4rt/heartdevs.com

Length of output: 1310


🏁 Script executed:

rg "nullOnDelete\(\)" --type php

Repository: he4rt/heartdevs.com

Length of output: 1763


🏁 Script executed:

grep -A1 "foreignId('tenant_id')" app-modules/gamification/database/migrations/2026_03_25_000004_create_character_items_table.php | head -5

Repository: he4rt/heartdevs.com

Length of output: 194


🏁 Script executed:

grep -A1 "foreignId('tenant_id')" app-modules/economy/database/migrations/2026_03_25_100001_create_shop_listings_table.php | head -5

Repository: he4rt/heartdevs.com

Length of output: 212


Add ->nullable() before foreign key constraint with nullOnDelete().

Using nullOnDelete() on a non-nullable column will cause database constraint violations when a tenant is deleted. The nullOnDelete() action attempts to set the column to NULL, which fails on non-nullable columns. This pattern appears systematically across multiple migrations.

-            $table->foreignId('tenant_id')->constrained('tenants')->nullOnDelete();
+            $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();

Note: This issue affects multiple tables in the codebase (character_items, item_rarities, item_slots, character_equipment, items, shop_listings, and others).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app-modules/economy/database/migrations/2026_03_25_100002_create_trades_table.php`
at line 15, The migration uses a non-nullable foreign key for tenant_id with
nullOnDelete (e.g. the line
foreignId('tenant_id')->constrained('tenants')->nullOnDelete() in
CreateTradesTable), which will fail when the DB tries to set NULL; update this
(and the same pattern in other migrations like character_items, item_rarities,
item_slots, character_equipment, items, shop_listings) to make the tenant_id
column nullable before applying the foreign key action by inserting ->nullable()
on the column definition (i.e.
foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete()), and
run migrations/tests to verify constraints behave as expected.

$table->foreignUuid('initiator_character_id')->constrained('characters')->cascadeOnDelete();
$table->foreignUuid('receiver_character_id')->constrained('characters')->cascadeOnDelete();
$table->string('status', 50)->default('pending');
$table->timestamp('resolved_at')->nullable();
$table->timestamps();

$table->index(['initiator_character_id', 'status']);
$table->index(['receiver_character_id', 'status']);
});
}

public function down(): void
{
Schema::dropIfExists('trades');
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::create('trade_items', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('trade_id')->constrained('trades')->cascadeOnDelete();
$table->foreignUuid('character_item_id')->constrained('character_items');
$table->string('direction', 20);
$table->timestamp('created_at')->nullable();
});
}

public function down(): void
{
Schema::dropIfExists('trade_items');
}
};
10 changes: 10 additions & 0 deletions app-modules/economy/src/Providers/EconomyServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

namespace He4rt\Economy\Providers;

use He4rt\Economy\Shop\Models\ShopListing;
use He4rt\Economy\Trade\Models\Trade;
use He4rt\Economy\Trade\Models\TradeItem;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;

class EconomyServiceProvider extends ServiceProvider
Expand All @@ -13,5 +17,11 @@ public function register(): void {}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../../database/migrations');

Relation::morphMap([
'shop_listing' => ShopListing::class,
'trade' => Trade::class,
'trade_item' => TradeItem::class,
]);
}
}
88 changes: 88 additions & 0 deletions app-modules/economy/src/Shop/Actions/PurchaseItem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace He4rt\Economy\Shop\Actions;

use He4rt\Economy\Actions\Debit;
use He4rt\Economy\DTOs\DebitDTO;
use He4rt\Economy\Exceptions\InsufficientBalanceException;
use He4rt\Economy\Shop\DTOs\PurchaseItemDTO;
use He4rt\Economy\Shop\Exceptions\ItemAlreadyOwnedException;
use He4rt\Economy\Shop\Exceptions\ItemNotAvailableException;
use He4rt\Economy\Shop\Exceptions\ItemOutOfStockException;
use He4rt\Economy\Shop\Exceptions\LevelRequirementNotMetException;
use He4rt\Economy\Shop\Models\ShopListing;
use He4rt\Gamification\Character\Inventory\Actions\AddItemToInventory;
use He4rt\Gamification\Character\Inventory\DTOs\AddItemToInventoryDTO;
use He4rt\Gamification\Character\Inventory\Models\CharacterItem;
use He4rt\Gamification\Character\Models\Character;
use He4rt\Gamification\Item\Enums\AcquisitionMethod;
use Illuminate\Support\Facades\DB;

final readonly class PurchaseItem
{
public function __construct(
private Debit $debit,
private AddItemToInventory $addItemToInventory,
) {}

/**
* @throws ItemNotAvailableException
* @throws ItemOutOfStockException
* @throws ItemAlreadyOwnedException
* @throws LevelRequirementNotMetException
* @throws InsufficientBalanceException
*/
public function handle(PurchaseItemDTO $dto): CharacterItem
{
$character = Character::query()->findOrFail($dto->characterId);
$listing = ShopListing::query()->with('item')->findOrFail($dto->shopListingId);

if (!$listing->isAvailable()) {
if ($listing->stock !== null && $listing->stock <= 0) {
throw ItemOutOfStockException::forListing($listing->id);
}

throw ItemNotAvailableException::forListing($listing->id);
}

$item = $listing->item;

if ($item->level_required > 0 && $character->level < $item->level_required) {
throw LevelRequirementNotMetException::forCharacter($character->level, $item->level_required);
}

$alreadyOwns = CharacterItem::query()
->where('character_id', $character->id)
->where('item_id', $item->id)
->exists();

if ($alreadyOwns) {
throw ItemAlreadyOwnedException::forCharacter($character->id, $item->id);

Check failure on line 62 in app-modules/economy/src/Shop/Actions/PurchaseItem.php

View workflow job for this annotation

GitHub Actions / Perform Phpstan Check / Run

Parameter #1 $characterId of static method He4rt\Economy\Shop\Exceptions\ItemAlreadyOwnedException::forCharacter() expects string, int given.
}

$wallet = $character->getOrCreateWallet();

return DB::transaction(function () use ($wallet, $listing, $character, $item): CharacterItem {
$this->debit->handle(new DebitDTO(
walletId: $wallet->id,
amount: $listing->price,
referenceType: 'shop_listing',
referenceId: $listing->id,
description: sprintf('Purchase: %s', $item->name),
));

if ($listing->stock !== null) {
$listing->decrement('stock');
}

return $this->addItemToInventory->handle(new AddItemToInventoryDTO(
characterId: $character->id,

Check failure on line 81 in app-modules/economy/src/Shop/Actions/PurchaseItem.php

View workflow job for this annotation

GitHub Actions / Perform Phpstan Check / Run

Parameter $characterId of class He4rt\Gamification\Character\Inventory\DTOs\AddItemToInventoryDTO constructor expects string, int given.
itemId: $item->id,
tenantId: $listing->tenant_id,
acquiredVia: AcquisitionMethod::Purchase,
));
Comment on lines +80 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Type mismatch: characterId expects string, receives int.

PHPStan confirms that AddItemToInventoryDTO constructor expects string for $characterId, but $character->id is int.

🐛 Proposed fix
             return $this->addItemToInventory->handle(new AddItemToInventoryDTO(
-                characterId: $character->id,
-                itemId: $item->id,
+                characterId: (string) $character->id,
+                itemId: (string) $item->id,
                 tenantId: $listing->tenant_id,
                 acquiredVia: AcquisitionMethod::Purchase,
             ));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return $this->addItemToInventory->handle(new AddItemToInventoryDTO(
characterId: $character->id,
itemId: $item->id,
tenantId: $listing->tenant_id,
acquiredVia: AcquisitionMethod::Purchase,
));
return $this->addItemToInventory->handle(new AddItemToInventoryDTO(
characterId: (string) $character->id,
itemId: (string) $item->id,
tenantId: $listing->tenant_id,
acquiredVia: AcquisitionMethod::Purchase,
));
🧰 Tools
🪛 GitHub Check: Perform Phpstan Check / Run

[failure] 81-81:
Parameter $characterId of class He4rt\Gamification\Character\Inventory\DTOs\AddItemToInventoryDTO constructor expects string, int given.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app-modules/economy/src/Shop/Actions/PurchaseItem.php` around lines 80 - 85,
Type mismatch: AddItemToInventoryDTO::characterId expects string but
$character->id is int; fix by converting the id to a string before constructing
the DTO (e.g., cast or strval) where PurchaseItem::handle calls new
AddItemToInventoryDTO with characterId: $character->id so the passed value is a
string; update the call in PurchaseItem (the new AddItemToInventoryDTO(...)
invocation) and ensure any related tests or usages still accept the string form
when passed to addItemToInventory->handle.

});
}
}
13 changes: 13 additions & 0 deletions app-modules/economy/src/Shop/DTOs/PurchaseItemDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace He4rt\Economy\Shop\DTOs;

final readonly class PurchaseItemDTO
{
public function __construct(
public string $characterId,
public string $shopListingId,
) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace He4rt\Economy\Shop\Exceptions;

use Exception;

final class ItemAlreadyOwnedException extends Exception
{
public static function forCharacter(string $characterId, string $itemId): self
{
return new self(
sprintf('Character %s already owns item %s.', $characterId, $itemId)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace He4rt\Economy\Shop\Exceptions;

use Exception;

final class ItemNotAvailableException extends Exception
{
public static function forListing(string $listingId): self
{
return new self(
sprintf('Shop listing %s is not currently available.', $listingId)
);
}
}
Loading
Loading