diff --git a/app/Http/Controllers/API/v1/TransactionController.php b/app/Http/Controllers/API/v1/TransactionController.php
index 3ff48cb2..3905f874 100644
--- a/app/Http/Controllers/API/v1/TransactionController.php
+++ b/app/Http/Controllers/API/v1/TransactionController.php
@@ -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
) {
}
@@ -293,25 +298,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:' . FileService::ALLOWED_EXTENSIONS . '|max:' . FileService::MAX_KILOBYTES,
- ]);
+ $validationResult = $this->validateRequestData($request);
if (! $validationResult['isValidated']) {
return $this->failure($validationResult['message'], $validationResult['code'], $validationResult['errors']);
@@ -320,6 +307,22 @@ public function store(Request $request): JsonResponse
$data = $validationResult['data'];
$user = $request->user();
+ //check if party_id is the user's "myself" party
+ //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);
+ //return response here to prevent controller from creating a third transaction below
+ // Extract the "Transaction" object from the transfer.
+ $transaction = $transfer->incomeTransaction;
+ return $this->success($transaction, statusCode: 201);
+ }
+
+
+
+
+
if (! empty($data['client_id'])) {
$existingTransaction = Transaction::findByClientId($data['client_id'], $user);
if ($existingTransaction) {
@@ -916,4 +919,63 @@ private function validateResourceOwnership(array $data, array $categories = []):
}
}
}
+
+ private function validateRequestData(Request $request): array
+ {
+ return $this->validateRequest($request, [
+ 'convert_myself_to_transfer' => 'sometimes|boolean', //'sometimes' to allow for the possibility of null entries
+ '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:' . FileService::ALLOWED_EXTENSIONS . '|max:' . FileService::MAX_KILOBYTES,
+
+ 'from_wallet_id' => 'required_if:convert_myself_to_transfer,true|integer|exists:wallets,id',
+ ]);
+ }
+
+ private function isMyselfTransfer($data, $user, $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 = $partyId ? $user->parties()->find($partyId) : null;
+
+ $isMyselfParty = $party && $party->getConfigValue('is-myself');
+
+ return $featureEnabled && $isMyselfParty && $request->has('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
+ ]
+ );
+ }
}
diff --git a/app/Models/Party.php b/app/Models/Party.php
index 3e8b1827..7e600819 100644
--- a/app/Models/Party.php
+++ b/app/Models/Party.php
@@ -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',
@@ -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;
use HasClientCreatedAt;
use HasFactory;
use Iconable;
diff --git a/app/Models/User.php b/app/Models/User.php
index c15230de..46b07236 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -133,4 +133,15 @@ public function preferredLocale(): ?string
return is_string($locale) && $locale !== '' ? $locale : null;
}
+ protected static function booted()
+ {
+ 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
+ );
+ });
+ }
}
diff --git a/database/seeders/ConfigurationSeeder.php b/database/seeders/ConfigurationSeeder.php
new file mode 100644
index 00000000..a7638baf
--- /dev/null
+++ b/database/seeders/ConfigurationSeeder.php
@@ -0,0 +1,23 @@
+setConfigValue('create-transfers-for-myself-transactions', true, \Whilesmart\ModelConfiguration\Enums\ConfigValueType::Boolean);
+ }
+
+
+
+
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
index 2f813bae..88c8a42e 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -32,5 +32,6 @@
+
diff --git a/public/docs/api.json b/public/docs/api.json
index 798ff7c3..ce072ee7 100644
--- a/public/docs/api.json
+++ b/public/docs/api.json
@@ -5564,6 +5564,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"
diff --git a/tests/Feature/MyselfTransferTest.php b/tests/Feature/MyselfTransferTest.php
new file mode 100644
index 00000000..212a4114
--- /dev/null
+++ b/tests/Feature/MyselfTransferTest.php
@@ -0,0 +1,101 @@
+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);
+ }
+}