Skip to content

feat: support for myself party type - #185

Closed
iMercyvlogs wants to merge 1 commit into
devfrom
feat/party-myself
Closed

iMercyvlogs wants to merge 1 commit into
devfrom
feat/party-myself

Conversation

@iMercyvlogs

Copy link
Copy Markdown
Collaborator

added support for party type myself.... Transactions "From" myself (Income) are handled as transfers if configured so. Target issue: #169

@iMercyvlogs
iMercyvlogs requested review from kofimokome and nfebe and removed request for kofimokome March 12, 2026 12:02
@sourceant

sourceant Bot commented Mar 12, 2026 •

Copy link
Copy Markdown

Code Review Summary

✨ Adds a myself party type and treats income recorded against it as money moving between the account holder's own wallets. PartyController::store()/update() now accept myself in the party-type enum, and StoreTransactionRequest gains convert_myself_to_transfer (boolean) and from_wallet_id (integer). In TransactionController::store(), when the referenced party belongs to the user and is typed myself, the request is routed to TransferService::transfer() instead of the normal write path; conversion is on by default, can be turned off per request via convert_myself_to_transfer: false, or per user via the new boolean configuration key transfer-myself-transactions (added to ConfigurationKeys names/types and to the public/docs/api.json config enum). The conversion returns the income leg of the resulting transfer so callers can spot transfer_id. A new tests/Feature/MyselfTransferTest.php covers balance movement, per-request opt-out, config opt-out, a missing source wallet (422), a non-myself party being left alone, and creating a myself party. Review attention centres on the new guard in TransactionController: it does not consider the transaction type, does not reject a self-referential source/destination wallet, and validates from_wallet_id with an unscoped exists rule; the duplicated party-type literal in PartyController is also flagged.

🚀 Key Improvements

  • New capability: income transactions against a myself party are recorded as transfers between the user's own wallets, with the receiving leg returned so clients can detect a transfer via transfer_id (app/Http/Controllers/API/v1/TransactionController.php).
  • Behaviour is configurable and defaults sensibly, with both a per-request override and a per-user boolean key registered in NAMES/TYPES and mirrored in the API docs (app/Support/ConfigurationKeys.php, public/docs/api.json).
  • tests/Feature/MyselfTransferTest.php covers the conversion, both opt-out paths, the missing-source-wallet error, an unrelated party type, and party creation as myself.

📉 Regressions

  • The conversion guard ignores $data['type'], so an expense against a myself party is converted too: wallet_id is the source wallet for expenses, so the money flow is inverted and an income leg is returned for an expense request — untested and wrong (app/Http/Controllers/API/v1/TransactionController.php).
  • from_wallet_id equal to the destination wallet_id is accepted, producing a transfer with the same wallet on both legs and an expense plus an income entry against one wallet, which can distort balance reconciliation (app/Http/Controllers/API/v1/TransactionController.php).
  • from_wallet_id is validated with an unscoped exists:wallets,id: a wallet belonging to another user passes validation and then throws a ModelNotFoundException from findOrFail, returning a framework 404 body instead of the {success, message, errors} failure shape used for a missing wallet (app/Http/Requests/StoreTransactionRequest.php, app/Http/Controllers/API/v1/TransactionController.php).
  • The valid party-type list is duplicated verbatim in PartyController::store() and update(); this change had to edit both, and future drift will make POST /parties and PUT /parties disagree on valid types (app/Http/Controllers/API/v1/PartyController.php).

💡 Minor Suggestions

  • Read the flag from the validated $data instead of the raw $request so the method depends only on validated input (and the Request parameter can be dropped). Also, the truthiness chain only recognises a few concrete representations of a boolean config value; normalising with filter_var(..., FILTER_VALIDATE_BOOLEAN) keeps the 'unset means on' default while correctly handling 'on'/'yes'/'off'/'0'. array_key_exists is used (rather than isset) so an explicit false is still honoured.
  • The valid party types are declared as a duplicated inline literal here (store()) and again identically in update() at line 306. This PR had to touch both lists to add myself; the next type added to only one list will cause POST /parties and PUT /parties to disagree on what a valid type is. Extract the list once and reference it from both validations.

🚨 Critical Issues

  • Guard against from_wallet_id equal to the destination wallet_id. As written, a self-transfer calls TransferService::transfer with the same wallet for both legs, which nets to zero but writes an expense and an income leg against one wallet and can distort balance reconciliation. Reject it explicitly with a 422.
  • The conversion is documented as applying to income transactions only, but the guard does not look at type. An expense sent against the 'myself' party takes this branch as well, producing a transfer whose fromWallet is from_wallet_id and toWallet is wallet_id — but for expenses wallet_id is the source wallet, so the transfer direction is inverted and an income leg is returned for an expense request.
  • from_wallet_id is validated with an unscoped exists:wallets,id. A wallet that does not exist returns the {success, message, errors} payload below, but a wallet that exists and belongs to another user passes validation and then throws a ModelNotFoundException from findOrFail, returning a framework 404 with a different body. Resolve the wallet once and report both cases through the same failure shape, keeping a single validator instead of adding a second one.
  • movesMoneyBetweenOwnWallets() never inspects $data['type'], yet it always builds the transfer as fromWallet = from_wallet_id and toWallet = wallet_id. For an income transaction that is correct (money lands in wallet_id). For an expense transaction wallet_id is the source wallet, so the same inputs invert the money flow — an expense-to-self would push money into wallet_id and out of from_wallet_id. The PR description scopes this feature to income ("Transactions 'From' myself (Income)"), and the tests only cover income, so the expense path is both untested and wrong. Guard the conversion on the transaction type.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

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);
}
}

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated
Comment thread database/migrations/2024_07_20_125104_create_parties_table.php Outdated
Comment thread tests/TestCase.php Outdated

@nfebe nfebe left a comment

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.

Thank for this! I took a quick look and have one comment for now :)

Comment thread database/migrations/2024_07_20_125104_create_parties_table.php Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated
Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated
@iMercyvlogs
iMercyvlogs requested a review from kofimokome March 13, 2026 04:43

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated

}

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

Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated

@kofimokome kofimokome left a comment

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.

Your tests are failing

@nfebe nfebe left a comment •

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.

  • We use whilesmart/eloquent-model-configs read the docs on and understand how to better set the configs

  • Inline I recommend the names of configs to set. is-myself set on party and create-transfers-for-myself-transactions. Its a boolean and is true by default. Which means if a user doesn't turn this off setting applies.

  • Many of the changes in phpunit, testcase.php, seem unrelated.

  • composer lock should not change

'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
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

Comment thread tests/Feature/MyselfTransferTest.php Outdated
Comment thread tests/Feature/MyselfTransferTest.php Outdated
Comment thread config/model-configuration.php Outdated
Comment thread config/app.php Outdated
Comment thread tests/TestCase.php Outdated
Comment thread phpunit.xml Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

]);
}

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.

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

Comment thread phpunit.xml Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

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.

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');
}

Comment thread phpunit.xml Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

$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);
}

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated
Comment thread database/seeders/ConfigurationSeeder.php Outdated
Comment thread composer.json
Comment thread phpunit.xml
Comment thread database/seeders/ConfigurationSeeder.php Outdated
Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

Comment thread app/Models/Party.php Outdated
)]
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;

Comment thread app/Models/User.php Outdated
{
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@iMercyvlogs
iMercyvlogs requested review from kofimokome and nfebe March 19, 2026 00:26
Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated
@kofimokome

Copy link
Copy Markdown
Collaborator

@iMercyvlogs you have two unresolved changes(suggestions) from nfebe.
Kindly resolve them. thanks

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

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.

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');
}

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

'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 app/Http/Controllers/API/v1/TransactionController.php Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@iMercyvlogs
iMercyvlogs requested a review from kofimokome April 28, 2026 20:22
Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated
'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
Collaborator

Choose a reason for hiding this comment

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

Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated
Comment thread app/Http/Controllers/API/v1/TransactionController.php Outdated

@nfebe nfebe left a comment

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.

No need to publish the configurations table and use FormRequest like Kofi mentioned.

Comment thread database/migrations/2025_06_30_120453_create_configurations_table.php Outdated

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.


$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']);

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);
});

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@github-actions

github-actions Bot commented May 4, 2026 •

Copy link
Copy Markdown

Coverage Report
PR coverage: 73.33%
Baseline: 73.26%
Change: ✅+0.1%

@iMercyvlogs
iMercyvlogs requested review from kofimokome and nfebe May 4, 2026 07:01
'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
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

@iMercyvlogs

Copy link
Copy Markdown
Collaborator Author

@kofimokome Notice that in the code, prior to being assigned the task of supporting myself party type the validation was done inlline through a custom validateRequest() method inherited from ApiController. So I simply moved the existing logic into a new helper method(validateRequestData()) with aim of reducing the store() method's complexity. Are you now saying I should change the validation strategy to rather use Laravel's built-in form request classes? In such a case, wouldn't I need to change all instances within the code base where validation was done?

@iMercyvlogs
iMercyvlogs requested a review from kofimokome May 10, 2026 22:13
@kofimokome

kofimokome commented May 11, 2026 •

Copy link
Copy Markdown
Collaborator

I strongly disagree with your last point. You would still need to use a form request.

You can decide to do it in another PR.

@nfebe nfebe left a comment

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.

@iMercyvlogs Use FormRequest that is the correct way to do it, if not there is not need to refactor this into a function.

Plus using the FormRequest will increase pattern usage of forms which is a good thing as opposed to writing class-based validators like this.

@iMercyvlogs

Copy link
Copy Markdown
Collaborator Author

@kofimokome @nfebe #232

@nfebe nfebe left a comment

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.

is_myself is not limited to transfers. That is already clear for transfers as users cannot transfer to anything else except a wallet they own.

So the is-myself config on parties make sense but it has/should have wider applications beyond transfers, it could also have a user specific config to enable the behavior.

Lets keep this open until I have time to look deeper and also research what will be best for UX.

Money moved between your own wallets was being recorded as income or
spending, which inflated both. A party can now stand for the account
holder, and a transaction against one moves the money instead. The
request can decline the conversion, and so can a setting.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

private TransactionWriter $transactionWriter,
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.

Read the flag from the validated $data instead of the raw $request so the method depends only on validated input (and the Request parameter can be dropped). Also, the truthiness chain only recognises a few concrete representations of a boolean config value; normalising with filter_var(..., FILTER_VALIDATE_BOOLEAN) keeps the 'unset means on' default while correctly handling 'on'/'yes'/'off'/'0'. array_key_exists is used (rather than isset) so an explicit false is still honoured.

Suggested change
}
+ if (array_key_exists('convert_myself_to_transfer', $data)) {
+ return (bool) $data['convert_myself_to_transfer'];
+ }
+
+ // Unset means on, the way the notification preferences read.
+ $preference = $user->getConfigValue(ConfigurationKeys::TRANSFER_MYSELF_TRANSACTIONS);
+
+ return $preference === null || filter_var($preference, FILTER_VALIDATE_BOOLEAN);


private function transferBetweenOwnWallets(array $data, User $user): JsonResponse
{
if (empty($data['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.

Guard against from_wallet_id equal to the destination wallet_id. As written, a self-transfer calls TransferService::transfer with the same wallet for both legs, which nets to zero but writes an expense and an income leg against one wallet and can distort balance reconciliation. Reject it explicitly with a 422.

Suggested change
if (empty($data['from_wallet_id'])) {
+ if (empty($data['from_wallet_id'])) {
+ return $this->failure(__('Server failed to validate request.'), 422, [
+ 'from_wallet_id' => [__('Name the wallet the money leaves, or send convert_myself_to_transfer as false.')],
+ ]);
+ }
+
+ if ((string) $data['from_wallet_id'] === (string) $data['wallet_id']) {
+ return $this->failure(__('Server failed to validate request.'), 422, [
+ 'from_wallet_id' => [__('The wallet the money leaves must be different from the wallet it arrives at.')],
+ ]);
+ }

Comment on lines +812 to +813
$partyId = $data['party_id'] ?? null;

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 conversion is documented as applying to income transactions only, but the guard does not look at type. An expense sent against the 'myself' party takes this branch as well, producing a transfer whose fromWallet is from_wallet_id and toWallet is wallet_id — but for expenses wallet_id is the source wallet, so the transfer direction is inverted and an income leg is returned for an expense request.

Suggested change
$partyId = $data['party_id'] ?? null;
$partyId = $data['party_id'] ?? null;
if ($partyId === null
|| ($data['type'] ?? '') !== 'income'
|| $user->parties()->whereKey($partyId)->value('type') !== 'myself') {
return false;
}

private TransactionWriter $transactionWriter,
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.

from_wallet_id is validated with an unscoped exists:wallets,id. A wallet that does not exist returns the {success, message, errors} payload below, but a wallet that exists and belongs to another user passes validation and then throws a ModelNotFoundException from findOrFail, returning a framework 404 with a different body. Resolve the wallet once and report both cases through the same failure shape, keeping a single validator instead of adding a second one.

Suggested change
}
$sourceWallet = $user->wallets()->whereKey($data['from_wallet_id'] ?? null)->first();
if ($sourceWallet === null) {
return $this->failure(__('Server failed to validate request.'), 422, [
'from_wallet_id' => [__('Name a wallet that belongs to you the money leaves, or send convert_myself_to_transfer as false.')],
]);
}
$transfer = $this->transferService->transfer(
amountToSend: (float) $data['amount'],
fromWallet: $sourceWallet,

'icon_type' => 'required_with:icon|string|in:icon,image,emoji',
// @phpcs:ignore
'type' => 'sometimes|string|in:individual,organization,business,partnership,non_profit,government_agency,educational_institution,healthcare_provider',
'type' => 'sometimes|string|in:myself,individual,organization,business,partnership,non_profit,government_agency,educational_institution,healthcare_provider',

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 valid party types are declared as a duplicated inline literal here (store()) and again identically in update() at line 306. This PR had to touch both lists to add myself; the next type added to only one list will cause POST /parties and PUT /parties to disagree on what a valid type is. Extract the list once and reference it from both validations.

Suggested change
'type' => 'sometimes|string|in:myself,individual,organization,business,partnership,non_profit,government_agency,educational_institution,healthcare_provider',
'type' => 'sometimes|string|'.self::PARTY_TYPES,

Comment on lines +812 to +813
$partyId = $data['party_id'] ?? null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

movesMoneyBetweenOwnWallets() never inspects $data['type'], yet it always builds the transfer as fromWallet = from_wallet_id and toWallet = wallet_id. For an income transaction that is correct (money lands in wallet_id). For an expense transaction wallet_id is the source wallet, so the same inputs invert the money flow — an expense-to-self would push money into wallet_id and out of from_wallet_id. The PR description scopes this feature to income ("Transactions 'From' myself (Income)"), and the tests only cover income, so the expense path is both untested and wrong. Guard the conversion on the transaction type.

Suggested change
$partyId = $data['party_id'] ?? null;
$partyId = $data['party_id'] ?? null;
// Only money arriving is re-interpreted as a transfer. Doing the same for
// an expense would move money into wallet_id and out of from_wallet_id.
if (($data['type'] ?? null) !== 'income') {
return false;
}
if ($partyId === null || $user->parties()->whereKey($partyId)->value('type') !== 'myself') {
return false;
}

@nfebe

nfebe commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Closing. Recording what came out of finishing this so it is not rediscovered.

A party type cannot drive the conversion, because it carries no direction. A transfer needs two wallets and a direction; a transaction has one wallet and a sign. "This party is me" supplies neither, so the endpoint has to guess, and it guesses from type being income. Spending on yourself from a wallet is refused outright:

POST /api/v1/transactions {"type":"expense","amount":50,"wallet_id":<euro>,"party_id":<myself>}
422 {"errors":{"from_wallet_id":["Name the wallet the money leaves, ..."]}}

The wallet is untouched and nothing is recorded, although the request already said which wallet the money left. Supplying from_wallet_id on an expense inverts the direction instead, because wallet_id is read as the destination: InvalidArgumentException: Insufficient balance in source wallet.

It also duplicates something that already works. POST /api/v1/transfers takes the same three inputs, and the clients already route there: ui/pages/transactions/new.vue:53 calls transfersApi.create, and mobile carries transfer_entity.dart and transfer_repository.dart.

Two bugs worth knowing about, independent of the design. $transfer->incomeTransaction does not exist; Transfer has transactions(), so that call returned null and the caller got an empty payload. And convert_myself_to_transfer was validated but never read, so the documented opt-out did nothing.

myself as a party type, without the conversion, was considered and dropped too. Nothing reads party type today, so a party a user simply names "Myself" behaves identically. It becomes worth adding when something consumes it: excluding self-movement from party_spending and party_income in StatsService, or enforcing one per user. Better added then, alongside the consumer.

Unrelated, noticed while here: getConfigValue() takes one argument. TransferService.php:32 and TransferController.php:209 pass a second as a default, and PHP ignores it. Both negate the result so null behaves like the intended false, but the default is not doing anything.

@nfebe nfebe closed this Sep 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants