Compare commits

...

12 Commits

Author SHA1 Message Date
hackerESQ a0bd776abb fix: quantity validation should not count current transaction 2025-08-29 15:47:38 -05:00
hackerESQ afcafa6031 chore: upgrade deps 2025-08-28 21:56:28 -05:00
hackerESQ 07c85697f3 chore: upgrade deps 2025-08-28 21:56:05 -05:00
hackerESQ a882b5aadb chore: clean up 2025-08-28 21:26:11 -05:00
hackerESQ bad82fb41b chore: cleanup old files 2025-08-28 21:26:11 -05:00
Shift 5aca9008cb Add .shift to open Pull Request 2025-08-28 21:26:11 -05:00
hackerESQ 712a4c6c57 docs: further clarification 2025-08-28 20:58:25 -05:00
hackerESQ 78f0d21b73 docs: clarify commands and update intro 2025-08-28 18:08:28 -05:00
hackerESQ 19cac58692 Fix: do not gracefully fail when symbol not found 2025-08-28 16:03:02 -05:00
hackerESQ 7d77b6fbc8 feat: add fix command for cost basis for sale transaction 2025-08-27 20:22:13 -05:00
hackerESQ e4e08091af fix: need to chunk alpaca history requests 2025-08-27 20:03:25 -05:00
hackerESQ 292d43b154 feat: adds cost basis fixer 2025-08-26 23:03:23 -05:00
16 changed files with 268 additions and 90 deletions
+4
View File
@@ -0,0 +1,4 @@
This file was added by Shift #157267 in order to open a
Pull Request since no other commits were made.
You should remove this file.
+12 -6
View File
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
## Under the hood ## Under the hood
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature many market data providers. But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode. Investbrain is a Laravel PHP web application that has an extensible market data provider interface. Out of the box, we feature many market data providers. But intrepid developers can [create their own providers](#custom-providers)! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
## Self hosting ## Self hosting
@@ -92,7 +92,7 @@ Your selected providers should be listed in your environment variables. Each sho
MARKET_DATA_PROVIDER=yahoo,alphavantage MARKET_DATA_PROVIDER=yahoo,alphavantage
``` ```
In the above example, Yahoo Finance will be attempted first and the Alpha Vantage provider will be used as the fallback. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage. In the above example, Yahoo Finance will be attempted first. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
### Custom providers ### Custom providers
@@ -181,7 +181,7 @@ Easy as that!
## Command line utilities ## Command line utilities
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Just to be safe, we recommend backing up your portfolios before using these commands.
To run these commands, you can use `docker exec` like this: To run these commands, you can use `docker exec` like this:
@@ -189,7 +189,12 @@ To run these commands, you can use `docker exec` like this:
docker exec -it investbrain-app php artisan <replace with command you want to run> docker exec -it investbrain-app php artisan <replace with command you want to run>
``` ```
Just to be safe, we recommend backing up your portfolios before using these commands: If you need more details on what the command does, you can take a look at the options available using the `help` option:
```bash
<command you want to run> --help
```
| Command | Description | | Command | Description |
| ------------- | ------------- | | ------------- | ------------- |
@@ -198,8 +203,9 @@ Just to be safe, we recommend backing up your portfolios before using these comm
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. | | refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. | | refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. | | capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) | | sync:daily-change | Syncs daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). | | sync:holdings | Syncs performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
| fix:cost-basis-for-sales | Utility to automatically re-calculates cost basis for sale transactions. |
## Troubleshooting ## Troubleshooting
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Portfolio;
use App\Models\Transaction;
use Illuminate\Console\Command;
class FixCostBasisForSales extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'fix:cost-basis-for-sales
{--portfolio= : The ID of the portfolio to fix.}
{--user= : The user ID of transactions to fix.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Fixes broken costs basis for sale transactions';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
if (empty($this->option('user')) && empty($this->option('portfolio'))) {
$this->error('Must provide at least a user or portfolio.');
return;
}
$transactions = Transaction::where(['transaction_type' => 'SELL']);
if ($this->option('user')) {
$portfolios = Portfolio::fullAccess($this->option('user'))->get('id')
->pluck('id')
->toArray();
$transactions->whereIn('portfolio_id', $portfolios);
} else {
$transactions->where(['portfolio_id' => $this->option('portfolio')]);
}
$transactions = $transactions->get();
$this->line("Fixing cost basis for {$transactions->count()} sale transactions...");
$transactions->chunk(10)->each(function ($chunk) {
dispatch(function () use ($chunk) {
$chunk->each(function ($transaction) {
$cost_basis = Transaction::where([
'portfolio_id' => $transaction->portfolio_id,
'symbol' => $transaction->symbol,
'transaction_type' => 'BUY',
])->whereDate('date', '<=', $transaction->date)
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
->selectRaw('SUM(transactions.quantity) as total_quantity')
->first();
$average_cost_basis = empty($cost_basis->total_quantity)
? 0
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
$transaction->cost_basis = $average_cost_basis ?? 0;
$transaction->save();
});
});
});
$this->line('Done!');
}
}
+2 -1
View File
@@ -39,7 +39,8 @@ class TransactionRequest extends FormRequest
$this->input('portfolio'), $this->input('portfolio'),
$this->requestOrModelValue('symbol', 'transaction'), $this->requestOrModelValue('symbol', 'transaction'),
$this->requestOrModelValue('transaction_type', 'transaction'), $this->requestOrModelValue('transaction_type', 'transaction'),
$this->requestOrModelValue('date', 'transaction') $this->requestOrModelValue('date', 'transaction'),
$this->transaction
), ),
], ],
'currency' => ['required', 'exists:currencies,currency'], 'currency' => ['required', 'exists:currencies,currency'],
+39 -7
View File
@@ -8,11 +8,13 @@ use App\Interfaces\MarketData\Types\Dividend;
use App\Interfaces\MarketData\Types\Ohlc; use App\Interfaces\MarketData\Types\Ohlc;
use App\Interfaces\MarketData\Types\Quote; use App\Interfaces\MarketData\Types\Quote;
use App\Interfaces\MarketData\Types\Split; use App\Interfaces\MarketData\Types\Split;
use Carbon\CarbonInterval;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AlpacaMarketData implements MarketDataInterface class AlpacaMarketData implements MarketDataInterface
{ {
@@ -23,6 +25,11 @@ class AlpacaMarketData implements MarketDataInterface
public string $apiBaseUrl = 'https://api.alpaca.markets/'; public string $apiBaseUrl = 'https://api.alpaca.markets/';
public function __construct() public function __construct()
{
$this->createNewClient();
}
private function createNewClient()
{ {
$this->client = Http::withOptions([ $this->client = Http::withOptions([
'headers' => [ 'headers' => [
@@ -45,15 +52,15 @@ class AlpacaMarketData implements MarketDataInterface
$quote = $response->json('trade'); $quote = $response->json('trade');
if (is_null(Arr::get($quote, 'p'))) { throw_if(empty(Arr::get($quote, 'p')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
throw new \Exception('Could not find ticker on Alpaca');
}
$fundamental = cache()->remember( $fundamental = cache()->remember(
'ap-symbol-'.$symbol, 'ap-symbol-'.$symbol,
1440, 1440,
function () use ($symbol) { function () use ($symbol) {
$this->createNewClient();
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json(); $basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([ $fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '12M', 'timeframe' => '12M',
@@ -125,16 +132,36 @@ class AlpacaMarketData implements MarketDataInterface
public function history(string $symbol, $startDate, $endDate): Collection public function history(string $symbol, $startDate, $endDate): Collection
{ {
$startDate = Carbon::parse($startDate);
$endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
$allHistory = collect();
$chunks = 1000;
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
foreach ($period as $startDate) {
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
if ($chunkEnd->gt($endDate)) {
$chunkEnd = $endDate;
}
$this->createNewClient();
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([ $response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
'timeframe' => '1D', 'timeframe' => '1D',
'start' => Carbon::parse($startDate)->format('Y-m-d'), 'start' => $startDate->format('Y-m-d'),
'end' => Carbon::parse($endDate)->subHours(36)->format('Y-m-d'), // todo: can't query recent SIP data 'end' => $chunkEnd->format('Y-m-d'),
])->get("v2/stocks/{$symbol}/bars"); ])->get("v2/stocks/{$symbol}/bars");
$history = $response->json('bars'); $history = $response->json('bars');
return collect($history) throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
->map(function ($history) use ($symbol) {
$chunkedHistory = collect($history)
->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse($history['t'])->format('Y-m-d'); $date = Carbon::parse($history['t'])->format('Y-m-d');
@@ -144,5 +171,10 @@ class AlpacaMarketData implements MarketDataInterface
'close' => Arr::get($history, 'c'), 'close' => Arr::get($history, 'c'),
])]; ])];
}); });
$allHistory = $allHistory->merge($chunkedHistory);
}
return $allHistory;
} }
} }
@@ -145,7 +145,7 @@ class AlphaVantageMarketData implements MarketDataInterface
return [$date => new Ohlc([ return [$date => new Ohlc([
'symbol' => $symbol, 'symbol' => $symbol,
'date' => $date, 'date' => $date,
'close' => Arr::get($history, '4. close'), 'close' => (float) Arr::get($history, '4. close'),
])]; ])];
}); });
} }
@@ -13,6 +13,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TwelveDataMarketData implements MarketDataInterface class TwelveDataMarketData implements MarketDataInterface
{ {
@@ -53,9 +54,7 @@ class TwelveDataMarketData implements MarketDataInterface
$quote = $response->json(); $quote = $response->json();
if (! isset($quote['price'])) { throw_if(empty(Arr::get($quote, 'price')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
throw new \Exception('Could not find ticker on Twelve Data');
}
$current_market_value = Arr::get($quote, 'price'); $current_market_value = Arr::get($quote, 'price');
@@ -152,9 +151,11 @@ class TwelveDataMarketData implements MarketDataInterface
]) ])
->get('time_series'); ->get('time_series');
$values = $response->json('values'); $history = $response->json('values');
return collect($values) throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
return collect($history)
->mapWithKeys(function ($history) use ($symbol) { ->mapWithKeys(function ($history) use ($symbol) {
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString(); $date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
+5 -3
View File
@@ -6,8 +6,8 @@ namespace App\Rules;
use App\Models\Portfolio; use App\Models\Portfolio;
use App\Models\Transaction; use App\Models\Transaction;
use Illuminate\Support\Carbon;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Carbon;
class QuantityValidationRule implements ValidationRule class QuantityValidationRule implements ValidationRule
{ {
@@ -20,8 +20,9 @@ class QuantityValidationRule implements ValidationRule
protected ?Portfolio $portfolio, protected ?Portfolio $portfolio,
protected ?string $symbol, protected ?string $symbol,
protected ?string $transactionType, protected ?string $transactionType,
protected string|Carbon|null $date protected string|Carbon|null $date,
) { } protected ?Transaction $transaction
) {}
/** /**
* Validate the attribute. * Validate the attribute.
@@ -42,6 +43,7 @@ class QuantityValidationRule implements ValidationRule
->sum('quantity'); ->sum('quantity');
$sales_qty = (float) $this->portfolio->transactions() $sales_qty = (float) $this->portfolio->transactions()
->where('id', '!=', $this->transaction?->id)
->symbol($this->symbol) ->symbol($this->symbol)
->sell() ->sell()
->whereDate('date', '<', $this->date) ->whereDate('date', '<', $this->date)
-16
View File
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Currency;
if (! function_exists('currency')) {
// /**
// * Returns an instance of the currency model
// * */
// function currency(): Currency
// {
// return new Currency;
// }
}
+1 -4
View File
@@ -35,7 +35,7 @@
"laravel/sail": "^1.26", "laravel/sail": "^1.26",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0", "nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0.1" "phpunit/phpunit": "^11.0"
}, },
"repositories": [ "repositories": [
{ {
@@ -55,9 +55,6 @@
} }
], ],
"autoload": { "autoload": {
"files": [
"app/Support/Helpers.php"
],
"psr-4": { "psr-4": {
"App\\": "app/", "App\\": "app/",
"Database\\Factories\\": "database/factories/", "Database\\Factories\\": "database/factories/",
Generated
+31 -31
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "d5d786656eb888c2966c648ddf946280", "content-hash": "09faf36704392ba5099e39fe62908057",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -62,16 +62,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.356.5", "version": "3.356.7",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "5872ccb5100c4afb0dae3db0bd46636f63ae8147" "reference": "6b44237a218485bf43a0015600aebf43cb726d4e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5872ccb5100c4afb0dae3db0bd46636f63ae8147", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/6b44237a218485bf43a0015600aebf43cb726d4e",
"reference": "5872ccb5100c4afb0dae3db0bd46636f63ae8147", "reference": "6b44237a218485bf43a0015600aebf43cb726d4e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -153,9 +153,9 @@
"support": { "support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions", "forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.356.5" "source": "https://github.com/aws/aws-sdk-php/tree/3.356.7"
}, },
"time": "2025-08-26T18:05:04+00:00" "time": "2025-08-28T18:14:39+00:00"
}, },
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -1803,21 +1803,21 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/hackeresq/filter-models", "url": "https://github.com/hackeresq/filter-models",
"reference": "847950d3277fe7df3a2dcdcdd3ba37d3b07ee667" "reference": "e92c1e1e8af299cb2c3c3e6d7768f2fb2dcb9146"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/hackeresq/filter-models/zipball/847950d3277fe7df3a2dcdcdd3ba37d3b07ee667", "url": "https://api.github.com/repos/hackeresq/filter-models/zipball/e92c1e1e8af299cb2c3c3e6d7768f2fb2dcb9146",
"reference": "847950d3277fe7df3a2dcdcdd3ba37d3b07ee667", "reference": "e92c1e1e8af299cb2c3c3e6d7768f2fb2dcb9146",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"laravel/framework": "^11.9", "laravel/framework": "^11.0||^12.0",
"php": "^8.2" "php": "^8.2"
}, },
"require-dev": { "require-dev": {
"orchestra/testbench": "^9.9", "orchestra/testbench": "^9.9",
"phpunit/phpunit": "^10.0|^11.0" "phpunit/phpunit": "^10.0||^11.0"
}, },
"default-branch": true, "default-branch": true,
"type": "library", "type": "library",
@@ -1839,7 +1839,7 @@
} }
}, },
"description": "Simple package to filter your Laravel models with query parameters", "description": "Simple package to filter your Laravel models with query parameters",
"time": "2025-01-27T23:18:08+00:00" "time": "2025-08-29T02:40:27+00:00"
}, },
{ {
"name": "investbrainapp/frankfurter-client", "name": "investbrainapp/frankfurter-client",
@@ -1847,16 +1847,16 @@
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/investbrainapp/frankfurter-client", "url": "https://github.com/investbrainapp/frankfurter-client",
"reference": "738b2b53f48b7cdf4d66c44a592430dea4de9fd0" "reference": "d2a96d7db2d17e91245b0cf2146e2b8a295b8d4b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/investbrainapp/frankfurter-client/zipball/738b2b53f48b7cdf4d66c44a592430dea4de9fd0", "url": "https://api.github.com/repos/investbrainapp/frankfurter-client/zipball/d2a96d7db2d17e91245b0cf2146e2b8a295b8d4b",
"reference": "738b2b53f48b7cdf4d66c44a592430dea4de9fd0", "reference": "d2a96d7db2d17e91245b0cf2146e2b8a295b8d4b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"laravel/framework": "^11.9", "laravel/framework": "^11.0||^12.0",
"php": "^8.2" "php": "^8.2"
}, },
"default-branch": true, "default-branch": true,
@@ -1874,7 +1874,7 @@
} }
}, },
"description": "Laravel SDK for interacting with the Frankfurter currency exchange API", "description": "Laravel SDK for interacting with the Frankfurter currency exchange API",
"time": "2025-04-11T02:35:18+00:00" "time": "2025-08-29T02:39:41+00:00"
}, },
{ {
"name": "jfcherng/php-color-output", "name": "jfcherng/php-color-output",
@@ -9911,16 +9911,16 @@
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "11.0.10", "version": "11.0.11",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "1a800a7446add2d79cc6b3c01c45381810367d76" "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/1a800a7446add2d79cc6b3c01c45381810367d76", "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
"reference": "1a800a7446add2d79cc6b3c01c45381810367d76", "reference": "4f7722aa9a7b76aa775e2d9d4e95d1ea16eeeef4",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9977,7 +9977,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/show" "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.11"
}, },
"funding": [ "funding": [
{ {
@@ -9997,7 +9997,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-06-18T08:56:18+00:00" "time": "2025-08-27T14:37:49+00:00"
}, },
{ {
"name": "phpunit/php-file-iterator", "name": "phpunit/php-file-iterator",
@@ -10246,16 +10246,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "11.5.34", "version": "11.5.35",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "3e4c6ef395f7cb61a6206c23e0e04b31724174f2" "reference": "d341ee94ee5007b286fc7907b383aae6b5b3cc91"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/3e4c6ef395f7cb61a6206c23e0e04b31724174f2", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d341ee94ee5007b286fc7907b383aae6b5b3cc91",
"reference": "3e4c6ef395f7cb61a6206c23e0e04b31724174f2", "reference": "d341ee94ee5007b286fc7907b383aae6b5b3cc91",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10269,7 +10269,7 @@
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1", "phar-io/version": "^3.2.1",
"php": ">=8.2", "php": ">=8.2",
"phpunit/php-code-coverage": "^11.0.10", "phpunit/php-code-coverage": "^11.0.11",
"phpunit/php-file-iterator": "^5.1.0", "phpunit/php-file-iterator": "^5.1.0",
"phpunit/php-invoker": "^5.0.1", "phpunit/php-invoker": "^5.0.1",
"phpunit/php-text-template": "^4.0.1", "phpunit/php-text-template": "^4.0.1",
@@ -10327,7 +10327,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.34" "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.35"
}, },
"funding": [ "funding": [
{ {
@@ -10351,7 +10351,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-08-20T14:41:45+00:00" "time": "2025-08-28T05:13:54+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",
+4 -2
View File
@@ -122,19 +122,21 @@ class TransactionFactory extends Factory
]); ]);
} }
public function buy(): static public function buy($quantity = 1): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'transaction_type' => 'BUY', 'transaction_type' => 'BUY',
'quantity' => $quantity,
'cost_basis' => $this->faker->randomFloat(2, 10, 500), 'cost_basis' => $this->faker->randomFloat(2, 10, 500),
'sale_price' => null, 'sale_price' => null,
]); ]);
} }
public function sell(): static public function sell($quantity = 1): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [
'transaction_type' => 'SELL', 'transaction_type' => 'SELL',
'quantity' => $quantity,
'sale_price' => $this->faker->randomFloat(2, 10, 500), 'sale_price' => $this->faker->randomFloat(2, 10, 500),
'cost_basis' => null, 'cost_basis' => null,
]); ]);
@@ -83,7 +83,7 @@ new class extends Component
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" /> <x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;"> <form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }} @csrf
</form> </form>
</x-dropdown> </x-dropdown>
@@ -19,7 +19,7 @@ new class extends Component
// props // props
public ?Portfolio $portfolio; public ?Portfolio $portfolio;
public ?Transaction $transaction; public ?Transaction $transaction = null;
public ?string $portfolio_id; public ?string $portfolio_id;
@@ -53,7 +53,7 @@ new class extends Component
'required', 'required',
'numeric', 'numeric',
'gt:0', 'gt:0',
new QuantityValidationRule($this->portfolio, $this->symbol, $this->transaction_type, $this->date), new QuantityValidationRule($this->portfolio, $this->symbol, $this->transaction_type, $this->date, $this->transaction),
], ],
'currency' => ['required', 'exists:currencies,currency'], 'currency' => ['required', 'exists:currencies,currency'],
'cost_basis' => 'exclude_if:transaction_type,SELL|min:0|numeric', 'cost_basis' => 'exclude_if:transaction_type,SELL|min:0|numeric',
+29
View File
@@ -114,6 +114,35 @@ class TransactionsTest extends TestCase
->assertJsonValidationErrors(['symbol']); ->assertJsonValidationErrors(['symbol']);
} }
public function test_cannot_sell_more_than_owned()
{
Artisan::call('db:seed', [
'--class' => CurrencySeeder::class,
'--force' => true,
]);
$this->actingAs($this->user);
$portfolio = Portfolio::factory()->create();
Transaction::factory(5)->buy()->lastYear()->portfolio($portfolio->id)->symbol('AAPL')->create();
$data = [
'symbol' => 'AAPL',
'portfolio_id' => $this->portfolio->id,
'transaction_type' => 'SELL',
'quantity' => 6,
'currency' => 'USD',
'date' => now()->toDateString(),
'sale_price' => 150,
];
$this->actingAs($this->user)
->postJson(route('api.transaction.store'), $data)
->assertUnprocessable()
->assertJsonValidationErrors(['quantity']);
}
public function test_can_show_a_transaction() public function test_can_show_a_transaction()
{ {
$this->actingAs($this->user); $this->actingAs($this->user);
+19
View File
@@ -8,6 +8,7 @@ use App\Models\Holding;
use App\Models\Portfolio; use App\Models\Portfolio;
use App\Models\Transaction; use App\Models\Transaction;
use App\Models\User; use App\Models\User;
use App\Rules\QuantityValidationRule;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
class TransactionsTest extends TestCase class TransactionsTest extends TestCase
@@ -69,4 +70,22 @@ class TransactionsTest extends TestCase
0.01 0.01
); );
} }
public function test_cannot_sell_more_than_owned(): void
{
$this->actingAs($user = User::factory()->create());
$portfolio = Portfolio::factory()->create();
Transaction::factory(5)->buy()->lastYear()->portfolio($portfolio->id)->symbol('AAPL')->create();
$sale_transaction = Transaction::factory()->sell(6)->lastMonth()->portfolio($portfolio->id)->symbol('AAPL')->make();
$rule = new QuantityValidationRule($portfolio, $sale_transaction->symbol, 'SELL', $sale_transaction->date, $sale_transaction);
$rule->validate('quantity', $sale_transaction->quantity, function () {
$this->assertFalse(false, 'Not permitted to sell more than owned.');
});
$this->assertTrue(true);
}
} }