Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e94acd2
enh: Myself party changed to transfer
iMercyvlogs Mar 12, 2026
8abd89c
chore: Resolve conflicts
iMercyvlogs Mar 12, 2026
7e2d3c4
enh: Myself party strictly by config no db change
iMercyvlogs Mar 13, 2026
81cc8e3
chore: Add helper functions in store 2 pass md test
iMercyvlogs Mar 13, 2026
37bc40f
chore: Fix code standards
iMercyvlogs Mar 13, 2026
2e777e1
chore: Added argument controller method to pass tests
iMercyvlogs Mar 16, 2026
5e166a2
chore: Change result to jsonresponse and take more user types
iMercyvlogs Mar 16, 2026
bbb2cc3
chore: Fix php code standards
iMercyvlogs Mar 16, 2026
58b2098
chore: Revert testcase and phpunit changes
iMercyvlogs Mar 16, 2026
b933206
enh: Import and use config package
iMercyvlogs Mar 16, 2026
8000581
chore: Coding standard tests pass
iMercyvlogs Mar 16, 2026
4e94566
chore: Use authenticated user in ismyselftransfer check n update conf…
iMercyvlogs Mar 19, 2026
cbdafc8
chore: Phpunit file content reverted to original
iMercyvlogs Mar 19, 2026
19ccd14
chore: Undo changes in composer files
iMercyvlogs Mar 29, 2026
49358f4
chore: Delete comments in transactioncontroller
iMercyvlogs Apr 12, 2026
6f15a38
chore: Delete comments in app config
iMercyvlogs Apr 13, 2026
ae50a3b
chore: Add helper function to pass phpmd
iMercyvlogs Apr 13, 2026
de2b185
chore: Helper functions to reduce cyclo complexity
iMercyvlogs Apr 13, 2026
c83223c
Revert "chore: Undo previous changes since they rather increase compl…
iMercyvlogs Apr 13, 2026
146607b
Revert "chore: Undo changes since they rather increase complexity to 90"
iMercyvlogs Apr 13, 2026
eb32e9c
fix: Suppress cyclocomplexity issue
iMercyvlogs Apr 20, 2026
63af365
enh: Add convertmyselftotransfer in payload
iMercyvlogs Apr 30, 2026
d1588b9
chore: Find party only when id is not null
iMercyvlogs Apr 30, 2026
a3c24f2
enh: Return transaction instead of transfer
iMercyvlogs Apr 30, 2026
378bab1
chore: Remove config migration table
iMercyvlogs Apr 30, 2026
4362f7f
chore: Merge conflicts
iMercyvlogs Apr 30, 2026
4f5dddb
chore: Reduce line characters to pass code standards test
iMercyvlogs Apr 30, 2026
55c059b
chore: Merge conflict
iMercyvlogs May 4, 2026
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
100 changes: 80 additions & 20 deletions app/Http/Controllers/API/v1/TransactionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@
use OpenApi\Attributes as OA;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
use App\Services\TransferService;

#[OA\Tag(name: 'Transactions', description: 'Endpoints for managing transactions')]
/**
* @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
*/
class TransactionController extends ApiController
{
use ApiQueryable;

public function __construct(
private RecurringTransactionService $recurringTransactionService
private RecurringTransactionService $recurringTransactionService,
private TransferService $transferService
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Retrieving the Party model directly in the controller and performing business logic here violates the Single Responsibility Principle. This logic should be encapsulated within the TransactionService or a specific HandleMyselfPartyAction. Additionally, using \App\Models\Party::find ignores the user scope; it should be scoped to the authenticated user for security.

Suggested change
) {
$partyId = $data['party_id'] ?? null;
if (config('app.convert_myself_to_transfer') && $partyId) {
$party = $user->parties()->find($partyId);
if ($party?->is_myself && $request->has('from_wallet_id')) {
$transfer = $this->transferService->transfer(
amountToSend: (float) $data['amount'],
fromWallet: $user->wallets()->findOrFail($request['from_wallet_id']),
amountToReceive: (float) $data['amount'],
toWallet: $user->wallets()->findOrFail($data['wallet_id']),
user: $user,
exchangeRate: 1.0,
datetime: $data['datetime'] ?? null,
transactionClientIds: ['income_transaction_client_id' => $data['client_id'] ?? null]
);
return $this->success($transfer, statusCode: 201);
}
}

}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This method performs a raw query on �App\Models\Configuration within the controller. This logic is repeated and should be moved to the User model or a ConfigurationService to improve maintainability and allow for caching.

Suggested change
}
private function isMyselfTransfer(array $data, User $user, Request $request): bool
{
$myselfPartyId = $user->getMyselfPartyId();
$isMyself = isset($data['party_id']) && (string)$data['party_id'] === (string)$myselfPartyId;
return config('app.convert_myself_to_transfer') &&
$isMyself &&
$request->has('from_wallet_id');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The isMyselfTransfer logic is still performing an extra database query via $user->parties()->find($partyId) for every transaction storage request. Since party_id is already validated to exist in the request, this could be optimized or cached. Additionally, the logic for determining if a party is 'myself' should reside on the Party model itself.

Suggested change
}
private function isMyselfTransfer(array $data, User $user, Request $request): bool
{
if (!$user->getConfigValue('create-transfers-for-myself-transactions') || !$request->has('from_wallet_id')) {
return false;
}
$partyId = $data['party_id'] ?? null;
if (!$partyId) {
return false;
}
$party = $user->parties()->find($partyId);
return $party && $party->getConfigValue('is-myself');
}


Expand Down Expand Up @@ -247,25 +252,7 @@ enum: ['daily', 'weekly', 'monthly', 'yearly']
*/
public function store(Request $request): JsonResponse
{
$validationResult = $this->validateRequest($request, [
'client_id' => ['nullable', 'string', new ValidateClientId()],
'amount' => 'required|numeric|min:0.01',
'type' => 'required|string|in:income,expense',
'description' => 'nullable|string',
'datetime' => ['nullable', new Iso8601DateTime()],
'created_at' => ['nullable', new Iso8601DateTime()],
'group_id' => 'nullable|integer|exists:groups,id',
'party_id' => 'nullable|integer|exists:parties,id',
'wallet_id' => 'required|integer|exists:wallets,id',
'categories' => 'nullable|array',
'is_recurring' => 'nullable|boolean',
'recurrence_period' => 'nullable|string|in:daily,weekly,monthly,yearly',
'recurrence_interval' => 'nullable|integer|min:1',
'recurrence_ends_at' => ['nullable', 'date', 'after:today', new Iso8601DateTime()],
'categories.*' => 'integer|exists:categories,id',
'files' => 'nullable|array',
'files.*' => 'file|mimes:jpg,jpeg,png,pdf|max:1240',
]);
$validationResult = $this->validateRequestData($request);

if (! $validationResult['isValidated']) {
return $this->failure($validationResult['message'], $validationResult['code'], $validationResult['errors']);
Expand All @@ -274,6 +261,20 @@ public function store(Request $request): JsonResponse
$data = $validationResult['data'];
$user = $request->user();

//check if party_id is the user's "myself" party

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

There is significant whitespace and some redundant logic flow here. The logic for 'myself' party conversion should ideally be handled inside the service layer or a dedicated action to keep the controller lean.

Suggested change
//check if party_id is the user's "myself" party
if ($this->isMyselfTransfer($data, $user, $request)) {
$transfer = $this->handleMyselfTransfer($data, $user, $request);
return $this->success($transfer, statusCode: 201);
}

//and if convert_myself_to_transfer is enabled in configuration. If so, create a transfer instead of a regular transaction
if (
$this->isMyselfTransfer($data, $user, $request)
) {
$transfer = $this->handleMyselfTransfer($data, $user, $request);
Comment thread
iMercyvlogs marked this conversation as resolved.
//return response here to prevent controller from creating a third transaction below
Comment thread
iMercyvlogs marked this conversation as resolved.
return $this->success($transfer, statusCode: 201);
}





if (! empty($data['client_id'])) {
$existingTransaction = Transaction::findByClientId($data['client_id'], $user);
if ($existingTransaction) {
Expand Down Expand Up @@ -824,4 +825,63 @@ private function validateResourceOwnership(array $data, array $categories = []):
}
}
}

private function validateRequestData(Request $request): array
{
return $this->validateRequest($request, [
'convert_myself_to_transfer' => 'sometimes|boolean',
'client_id' => ['nullable', 'string', new ValidateClientId()],
'amount' => 'required|numeric|min:0.01',
'type' => 'required|string|in:income,expense',
'description' => 'nullable|string',
'datetime' => ['nullable', new Iso8601DateTime()],
'created_at' => ['nullable', new Iso8601DateTime()],
'group_id' => 'nullable|integer|exists:groups,id',
'party_id' => 'nullable|integer|exists:parties,id',
'wallet_id' => 'required|integer|exists:wallets,id',
'categories' => 'nullable|array',
'is_recurring' => 'nullable|boolean',
'recurrence_period' => 'nullable|string|in:daily,weekly,monthly,yearly',
'recurrence_interval' => 'nullable|integer|min:1',
'recurrence_ends_at' => ['nullable', 'date', 'after:today', new Iso8601DateTime()],
'categories.*' => 'integer|exists:categories,id',
'files' => 'nullable|array',
'files.*' => 'file|mimes:jpg,jpeg,png,pdf|max:1240',

'from_wallet_id' => 'required_if:convert_myself_to_transfer,true|integer|exists:wallets,id',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The validation rule required_if:convert_myself_to_transfer,true uses a hardcoded string key that isn't part of the request payload. It should likely check the user's config or handle this requirement logically, as convert_myself_to_transfer isn't a field in the request.

Suggested change
'from_wallet_id' => 'required_if:convert_myself_to_transfer,true|integer|exists:wallets,id',
'from_wallet_id' => 'nullable|integer|exists:wallets,id',

Comment thread
iMercyvlogs marked this conversation as resolved.
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
]);

Create a request instead and there should likely be a dedicated commit for that

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

sorry, I didn't quite understand this

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You haven't updated the codes to use Laravel Requests

}

private function isMyselfTransfer($data, $user, $request): bool

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The method signature in the implementation includes $request, but the call site on line 263 only passes two arguments. This will cause a TypeError if line 862 is reached.

Suggested change
private function isMyselfTransfer($data, $user, $request): bool
private function isMyselfTransfer($data, $user): bool

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Type hinting the parameters improves code clarity and helps static analysis tools catch errors.

Suggested change
private function isMyselfTransfer($data, $user, $request): bool
private function isMyselfTransfer(array $data, \App\Models\User $user, Request $request): bool

{
//get user preferences (boolean)
$featureEnabled = $user->getConfigValue('create-transfers-for-myself-transactions');

//identify the party and check its 'is_myself' property;
$partyId = $data['party_id'] ?? null;
$party = $user->parties()->find($partyId);
Comment thread
iMercyvlogs marked this conversation as resolved.
Outdated

$isMyselfParty = $party && $party->getConfigValue('is-myself');

return $featureEnabled && $isMyselfParty && $request->has('from_wallet_id');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The check $request->has('from_wallet_id') is redundant because the validation rules (line 851) already enforce required_if:convert_myself_to_transfer,true. However, if convert_myself_to_transfer is false but the key exists, this logic might still trigger unexpectedly if the other conditions meet. It's safer to rely strictly on the validated data.

Suggested change
return $featureEnabled && $isMyselfParty && $request->has('from_wallet_id');
return $featureEnabled && $isMyselfParty && isset($data['from_wallet_id']);

}

private function handleMyselfTransfer($data, $user, $request)
{
$fromWallet = $user->wallets()->findOrFail($request['from_wallet_id']);
$toWallet = $user->wallets()->findOrFail($data['wallet_id']);

return $this->transferService->transfer(
amountToSend: (float) $data['amount'],
fromWallet: $fromWallet,
amountToReceive: (float) $data['amount'],
toWallet: $toWallet,
user: $user,
exchangeRate: 1.0,
datetime: $data['datetime'] ?? null,
transactionClientIds: [
'income_transaction_client_id' => $data['client_id'] ?? null
]
);
}
}
3 changes: 3 additions & 0 deletions app/Models/Party.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use OpenApi\Attributes as OA;
use Whilesmart\ModelConfiguration\Traits\Configurable;

#[OA\Schema(
schema: 'Party',
Expand All @@ -21,12 +22,14 @@
new OA\Property(property: 'id', description: 'ID of the icon', type: 'integer'),
new OA\Property(property: 'path', description: 'Image of the icon', type: 'string'),
new OA\Property(property: 'type', description: 'type of icon( image or icon or emoji)', type: 'string'),
new OA\Property(property: 'is_myself', description: 'Whether this party represents the user themselves', type: 'boolean'),
], type: 'object'),
],
type: 'object'
)]
class Party extends Model
{
use configurable;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The trait name should follow PascalCase convention for consistency, although PHP is case-insensitive for traits.

Suggested change
use configurable;
use Configurable;

use HasClientCreatedAt;
use HasFactory;
use Iconable;
Expand Down
11 changes: 11 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,15 @@ public function getAvatarUrlAttribute(): ?string
{
return $this->getConfigValue('avatar');
}
protected static function booted()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Using the booted static method for logic like this can be fragile during bulk imports or migrations. It is generally better to handle default user settings via a Service/Action class when a user is registered, or use database defaults where possible.

Suggested change
protected static function booted()
// Consider moving this logic to a User Registration Service or an Observer.

{
static::created(function ($user) {
// Automatically enable this setting for every new user
$user->setConfigValue(
'create-transfers-for-myself-transactions',
true,
\Whilesmart\ModelConfiguration\Enums\ConfigValueType::Boolean
);
});
}
}
1 change: 1 addition & 0 deletions config/model-configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
'notifications-insights' => 'boolean',
'notifications-inactivity' => 'boolean',
'insights-frequency' => 'string|in:daily,weekly,monthly',

],
'model' => \App\Models\Configuration::class,
'hooks' => [ModelConfigurationFilterHook::class],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

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

return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('configurations', function (Blueprint $table) {
$table->id();
$table->string('key');
$table->json('value');
$table->string('configurable_type');
$table->unsignedBigInteger('configurable_id');
$table->string('type')->default('string');
$table->timestamps();

$table->unique(['configurable_id', 'configurable_type', 'key']);
Comment thread
iMercyvlogs marked this conversation as resolved.
Outdated
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('configurations');
}
};
23 changes: 23 additions & 0 deletions database/seeders/ConfigurationSeeder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class ConfigurationSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//enable feature for all existing users by default, can be turned off by user if they want
foreach (\App\Models\User::all() as $user) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Iterating through all users and executing a query for each in a seeder is inefficient for large datasets. Consider using a bulk insert or a more performant update strategy.

Suggested change
foreach (\App\Models\User::all() as $user) {
\App\Models\User::all()->each(function ($user) {
$user->setConfigValue('create-transfers-for-myself-transactions', true, \Whilesmart\ModelConfiguration\Enums\ConfigValueType::Boolean);
});

$user->setConfigValue('create-transfers-for-myself-transactions', true, \Whilesmart\ModelConfiguration\Enums\ConfigValueType::Boolean);
}




}
}
1 change: 1 addition & 0 deletions phpunit.xml
Comment thread
iMercyvlogs marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@
<env name="SESSION_DRIVER" value="array"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="FIREBASE_CREDENTIALS" value=""/>

</php>
</phpunit>
4 changes: 4 additions & 0 deletions public/docs/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -4111,6 +4111,10 @@
"type": {
"description": "type of icon( image or icon or emoji)",
"type": "string"
},
"is_myself": {
"description": "Whether this party represents the user themselves",
"type": "boolean"
}
},
"type": "object"
Expand Down
101 changes: 101 additions & 0 deletions tests/Feature/MyselfTransferTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

namespace Tests\Feature;

use App\Models\Party;
use App\Models\User;
use App\Models\Wallet;
use App\Models\Transaction;
use App\Models\Transfer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Whilesmart\ModelConfiguration\Enums\ConfigValueType;

class MyselfTransferTest extends TestCase
{
use RefreshDatabase;

public function test_income_from_myself_is_handled_as_transfer()
{
// 1. Setup Data
$user = User::factory()->create();
$party = Party::factory()->create(['user_id' => $user->id]);

//set user preference to use "myself" party
$user->setConfigValue(
'create-transfers-for-myself-transactions',
true,
ConfigValueType::Boolean
);
//set party flag
$party->setConfigValue(
'is-myself',
true,
ConfigValueType::Boolean
);

//create the myself party
$walletSource = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 1000]);
$walletDest = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 0]);



$fakeClientId = \Illuminate\Support\Str::uuid() . ':' . \Illuminate\Support\Str::uuid();
// 2. The Payload
$payload = [
'amount' => 200, // Validator needs 'amount'
'type' => 'income', // Validator needs 'type'
'party_id' => $party->id, // Triggers the 'is_myself' logic
'wallet_id' => $walletDest->id, // Becomes 'toWallet'
'from_wallet_id' => $walletSource->id, // Becomes 'fromWallet'
'client_id' => $fakeClientId, // To ensure idempotency in tests
'description' => 'Moving cash to bank',
'datetime' => now()->toIso8601String(),
];

// 3. Act
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/v1/transactions', $payload);

// 4. Assertions
$response->assertStatus(201);

// Check if a Transfer record was created instead of just a loose transaction
$this->assertDatabaseHas('transfers', [
'from_wallet_id' => $walletSource->id,
'to_wallet_id' => $walletDest->id,
'amount' => 200,
]);

// Check balances
$this->assertEquals(800, $walletSource->fresh()->balance);
$this->assertEquals(200, $walletDest->fresh()->balance);

// Ensure the transaction is linked to a transfer
$this->assertNotNull(Transaction::where('amount', 200)->first()->transfer_id);
}

public function test_normal_income_stays_as_transaction()
{
$user = User::factory()->create();
$wallet = Wallet::factory()->create(['user_id' => $user->id, 'balance' => 0]);
$normalParty = Party::factory()->create([
'user_id' => $user->id,
]);

$payload = [
'amount' => 100,
'type' => 'income',
'party_id' => $normalParty->id,
'wallet_id' => $wallet->id,
];

$this->actingAs($user, 'sanctum')
->postJson('/api/v1/transactions', $payload)
->assertStatus(201);

// Should NOT create a transfer
$this->assertEquals(0, Transfer::count());
$this->assertEquals(100, $wallet->fresh()->balance);
}
}