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