diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..a761446 --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,25 @@ +name: Gate + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + checks: write + pull-requests: write + contents: read + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run Gate + uses: synapse-sentinel/gate@master + with: + coverage-threshold: 80 + coverage-directory: src + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/composer.json b/composer.json index 94adc08..4bc6995 100644 --- a/composer.json +++ b/composer.json @@ -7,11 +7,14 @@ "php": "^8.3", "illuminate/support": "^11.0|^12.0", "prism-php/prism": "^0.99", - "saloonphp/saloon": "^3.0" + "saloonphp/saloon": "^4.0" }, "require-dev": { + "larastan/larastan": "^3.0", + "laravel/pint": "^1.0", "orchestra/testbench": "^9.0|^10.0", - "pestphp/pest": "^3.0" + "pestphp/pest": "^3.0", + "pestphp/pest-plugin-laravel": "^3.0" }, "autoload": { "psr-4": { @@ -20,7 +23,17 @@ }, "autoload-dev": { "psr-4": { - "TheShit\\Finance\\Tests\\": "tests/" + "Tests\\": "tests/" + } + }, + "scripts": { + "test": "pest", + "analyse": "phpstan analyse", + "format": "pint" + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true } }, "extra": { diff --git a/config/finance.php b/config/finance.php index 007d654..288344c 100644 --- a/config/finance.php +++ b/config/finance.php @@ -3,12 +3,13 @@ return [ 'plaid' => [ - 'client_id' => env('PLAID_CLIENT_ID'), - 'secret' => env('PLAID_SECRET'), - 'environment' => env('PLAID_ENVIRONMENT', 'sandbox'), // sandbox | development | production - 'products' => ['transactions'], + 'client_id' => env('PLAID_CLIENT_ID'), + 'secret' => env('PLAID_SECRET'), + 'access_token' => env('PLAID_ACCESS_TOKEN'), // permanent token from ExchangePublicToken + 'environment' => env('PLAID_ENVIRONMENT', 'sandbox'), // sandbox | development | production + 'products' => ['transactions'], 'country_codes' => ['US'], - 'webhook' => env('PLAID_WEBHOOK_URL'), + 'webhook' => env('PLAID_WEBHOOK_URL'), ], 'privacy' => [ diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..09d2a80 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + paths: + - src + level: 8 + checkMissingIterableValueType: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ee77cfd --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests/Unit + + + tests/Feature + + + + + src + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..923c341 --- /dev/null +++ b/pint.json @@ -0,0 +1,7 @@ +{ + "preset": "laravel", + "rules": { + "ordered_imports": { "sort_algorithm": "alpha" }, + "no_unused_imports": true + } +} diff --git a/src/FinanceServiceProvider.php b/src/FinanceServiceProvider.php index 9e44239..fadc73b 100644 --- a/src/FinanceServiceProvider.php +++ b/src/FinanceServiceProvider.php @@ -3,9 +3,11 @@ namespace TheShit\Finance; use Illuminate\Support\ServiceProvider; +use TheShit\Finance\Contracts\FinanceDataProvider; use TheShit\Finance\Plaid\PlaidConnector; use TheShit\Finance\Privacy\AmountBucketer; use TheShit\Finance\Privacy\PrivacyTransformer; +use TheShit\Finance\Providers\PlaidProvider; class FinanceServiceProvider extends ServiceProvider { @@ -21,6 +23,13 @@ public function register(): void ); }); + $this->app->singleton(FinanceDataProvider::class, function ($app) { + return new PlaidProvider( + connector: $app->make(PlaidConnector::class), + accessToken: config('finance.plaid.access_token'), + ); + }); + $this->app->singleton(PrivacyTransformer::class, function () { return new PrivacyTransformer( bucketer: new AmountBucketer, diff --git a/src/Privacy/PrivacyTransformer.php b/src/Privacy/PrivacyTransformer.php index 1595635..9f2cc1c 100644 --- a/src/Privacy/PrivacyTransformer.php +++ b/src/Privacy/PrivacyTransformer.php @@ -2,7 +2,8 @@ namespace TheShit\Finance\Privacy; -use EchoLabs\Prism\Facades\Prism; +use Prism\Prism\Enums\Provider; +use Prism\Prism\Facades\Prism; use Illuminate\Support\Collection; use TheShit\Finance\Plaid\DTOs\Transaction; @@ -99,7 +100,7 @@ private function ollamaReduce(array $stripped, string $task): CloudPayload ]); $response = Prism::text() - ->usingOllama($this->model, $this->endpoint) + ->using(Provider::Ollama, $this->model) ->withSystemPrompt(self::SYSTEM_PROMPT) ->withPrompt($prompt) ->generate(); diff --git a/src/Tools/Concerns/ResolvePeriod.php b/src/Tools/Concerns/ResolvePeriod.php new file mode 100644 index 0000000..995a19a --- /dev/null +++ b/src/Tools/Concerns/ResolvePeriod.php @@ -0,0 +1,31 @@ + [ + Carbon::now()->startOfMonth()->subMonthNoOverflow(), + Carbon::now()->subMonthNoOverflow()->endOfMonth(), + ], + 'last_30_days' => [ + Carbon::now()->subDays(30)->startOfDay(), + Carbon::now()->endOfDay(), + ], + default => [ // current_month + Carbon::now()->startOfMonth(), + Carbon::now()->endOfDay(), + ], + }; + } +} diff --git a/src/Tools/FinanceTools.php b/src/Tools/FinanceTools.php new file mode 100644 index 0000000..265bd96 --- /dev/null +++ b/src/Tools/FinanceTools.php @@ -0,0 +1,42 @@ +withTools(FinanceTools::all()); + * $agent->withTools([GetSpendingSummaryTool::make(), ...]); + */ +class FinanceTools +{ + /** + * All core finance tools, ready to spread into any Prism agent. + * + * @return Tool[] + */ + public static function all(): array + { + return [ + GetSpendingSummaryTool::make(), + GetAccountBalancesTool::make(), + GetCashflowTool::make(), + ]; + } + + /** + * Spending analysis only. + * + * @return Tool[] + */ + public static function spending(): array + { + return [ + GetSpendingSummaryTool::make(), + GetCashflowTool::make(), + ]; + } +} diff --git a/src/Tools/GetAccountBalancesTool.php b/src/Tools/GetAccountBalancesTool.php new file mode 100644 index 0000000..42ed9e4 --- /dev/null +++ b/src/Tools/GetAccountBalancesTool.php @@ -0,0 +1,36 @@ +for('Get current balances for all connected bank and credit accounts. Use this to answer questions about available funds, credit limits, or total net worth across accounts.') + ->using(function () use ($provider): string { + $accounts = $provider->accounts(); + + // Balances are structural, not transactional — safe to summarize + // without merchant-level PII. We round to nearest $10 for cloud. + $summary = $accounts->map(fn ($account) => [ + 'type' => $account->type, + 'subtype' => $account->subtype, + 'available' => $account->available !== null + ? '$'.number_format(round($account->available / 10) * 10) + : null, + 'current' => '$'.number_format(round($account->current / 10) * 10), + 'limit' => $account->limit !== null + ? '$'.number_format($account->limit) + : null, + ])->values()->toArray(); + + return json_encode($summary, JSON_PRETTY_PRINT); + }); + } +} diff --git a/src/Tools/GetCashflowTool.php b/src/Tools/GetCashflowTool.php new file mode 100644 index 0000000..4762bd3 --- /dev/null +++ b/src/Tools/GetCashflowTool.php @@ -0,0 +1,40 @@ +for('Get income vs expenses for a period. Use this to answer questions about whether spending exceeds income, savings rate, or overall financial health.') + ->withStringParameter( + name: 'period', + description: 'The time period to analyze. Options: current_month, last_month, last_30_days. Defaults to current_month.', + required: false, + ) + ->using(function (string $period = 'current_month') use ($provider, $transformer): string { + [$from, $to] = self::resolvePeriod($period); + + $transactions = $provider->transactions($from, $to) + ->filter(fn ($t) => ! $t->pending); + + $payload = $transformer->prepare( + $transactions, + 'Calculate net cashflow: total income (credits) vs total expenses (debits). Include category breakdown for expenses.' + ); + + return $payload->toJson(); + }); + } +} diff --git a/src/Tools/GetSpendingSummaryTool.php b/src/Tools/GetSpendingSummaryTool.php new file mode 100644 index 0000000..d8f5a1f --- /dev/null +++ b/src/Tools/GetSpendingSummaryTool.php @@ -0,0 +1,40 @@ +for('Get a breakdown of spending by category for a given period. Use this to answer questions about where money is being spent.') + ->withStringParameter( + name: 'period', + description: 'The time period to analyze. Options: current_month, last_month, last_30_days. Defaults to current_month.', + required: false, + ) + ->using(function (string $period = 'current_month') use ($provider, $transformer): string { + [$from, $to] = self::resolvePeriod($period); + + $transactions = $provider->transactions($from, $to) + ->filter(fn ($t) => ! $t->pending); + + $payload = $transformer->prepare( + $transactions, + 'Summarize spending by category — totals, counts, and any notable patterns.' + ); + + return $payload->toJson(); + }); + } +} diff --git a/tests/Feature/FinanceServiceProviderTest.php b/tests/Feature/FinanceServiceProviderTest.php new file mode 100644 index 0000000..f0bd2dd --- /dev/null +++ b/tests/Feature/FinanceServiceProviderTest.php @@ -0,0 +1,50 @@ +toBeInstanceOf(PlaidConnector::class) + ->and($a)->toBe($b); +}); + +it('binds FinanceDataProvider to PlaidProvider', function () { + $provider = app(FinanceDataProvider::class); + + expect($provider)->toBeInstanceOf(PlaidProvider::class); +}); + +it('binds FinanceDataProvider as singleton', function () { + $a = app(FinanceDataProvider::class); + $b = app(FinanceDataProvider::class); + + expect($a)->toBe($b); +}); + +it('binds PrivacyTransformer as singleton', function () { + $a = app(PrivacyTransformer::class); + $b = app(PrivacyTransformer::class); + + expect($a)->toBeInstanceOf(PrivacyTransformer::class) + ->and($a)->toBe($b); +}); + +it('configures plaid connector with sandbox environment from test config', function () { + $connector = app(PlaidConnector::class); + + expect($connector->resolveBaseUrl())->toBe('https://sandbox.plaid.com'); +}); + +it('configures privacy transformer with passthrough driver from test config', function () { + // TestCase sets FINANCE_PRIVACY_DRIVER=passthrough + // Verify the transformer doesn't attempt Ollama calls + $transformer = app(PrivacyTransformer::class); + $payload = $transformer->prepare(collect(), 'test task'); + + expect($payload->data)->toBe([]); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..7556164 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,3 @@ +in('Feature', 'Unit'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..6e22108 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,23 @@ +set('finance.plaid.client_id', 'test-client-id'); + $app['config']->set('finance.plaid.secret', 'test-secret'); + $app['config']->set('finance.plaid.access_token', 'test-access-token'); + $app['config']->set('finance.plaid.environment', 'sandbox'); + $app['config']->set('finance.privacy.driver', 'passthrough'); + } +} diff --git a/tests/Unit/Contracts/SyncResultTest.php b/tests/Unit/Contracts/SyncResultTest.php new file mode 100644 index 0000000..32b5538 --- /dev/null +++ b/tests/Unit/Contracts/SyncResultTest.php @@ -0,0 +1,37 @@ +added)->toBe($added) + ->and($result->modified)->toBe($modified) + ->and($result->removed)->toBe($removed) + ->and($result->nextCursor)->toBe('cursor_abc123') + ->and($result->hasMore)->toBeTrue(); +}); + +it('accepts hasMore as false', function () { + $result = new SyncResult( + added: collect(), + modified: collect(), + removed: collect(), + nextCursor: '', + hasMore: false, + ); + + expect($result->hasMore)->toBeFalse() + ->and($result->added)->toBeInstanceOf(Collection::class); +}); diff --git a/tests/Unit/Plaid/DTOs/AccountTest.php b/tests/Unit/Plaid/DTOs/AccountTest.php new file mode 100644 index 0000000..0b0252b --- /dev/null +++ b/tests/Unit/Plaid/DTOs/AccountTest.php @@ -0,0 +1,66 @@ + 'acc_123', + 'name' => 'Checking', + 'official_name' => 'Premier Checking Account', + 'type' => 'depository', + 'subtype' => 'checking', + 'balances' => [ + 'available' => 1500.00, + 'current' => 1600.00, + 'limit' => null, + 'iso_currency_code' => 'USD', + ], + ]); + + expect($account->accountId)->toBe('acc_123') + ->and($account->name)->toBe('Checking') + ->and($account->officialName)->toBe('Premier Checking Account') + ->and($account->type)->toBe('depository') + ->and($account->subtype)->toBe('checking') + ->and($account->available)->toBe(1500.00) + ->and($account->current)->toBe(1600.00) + ->and($account->limit)->toBeNull() + ->and($account->currencyCode)->toBe('USD'); +}); + +it('falls back to name when official_name is missing', function () { + $account = Account::fromPlaid([ + 'account_id' => 'acc_456', + 'name' => 'Savings', + 'type' => 'depository', + 'subtype' => 'savings', + 'balances' => [ + 'available' => null, + 'current' => 5000.00, + 'limit' => null, + 'iso_currency_code' => 'USD', + ], + ]); + + expect($account->officialName)->toBe('Savings') + ->and($account->available)->toBeNull(); +}); + +it('maps credit account with limit', function () { + $account = Account::fromPlaid([ + 'account_id' => 'acc_cc', + 'name' => 'Visa', + 'official_name' => 'Visa Signature', + 'type' => 'credit', + 'subtype' => 'credit card', + 'balances' => [ + 'available' => 4000.00, + 'current' => 1000.00, + 'limit' => 5000.00, + 'iso_currency_code' => 'USD', + ], + ]); + + expect($account->limit)->toBe(5000.00) + ->and($account->type)->toBe('credit'); +}); diff --git a/tests/Unit/Plaid/DTOs/TransactionTest.php b/tests/Unit/Plaid/DTOs/TransactionTest.php new file mode 100644 index 0000000..d33ba2e --- /dev/null +++ b/tests/Unit/Plaid/DTOs/TransactionTest.php @@ -0,0 +1,89 @@ + 'txn_abc', + 'account_id' => 'acc_123', + 'amount' => 42.50, + 'iso_currency_code' => 'USD', + 'name' => 'Whole Foods Market', + 'merchant_name' => 'Whole Foods', + 'date' => '2026-04-01', + 'authorized_datetime' => '2026-04-01T10:00:00Z', + 'category' => ['Food and Drink', 'Groceries'], + 'category_id' => 'cat_001', + 'payment_channel' => 'in store', + 'pending' => false, + 'logo_url' => 'https://logo.example.com', + 'website' => 'wholefoodsmarket.com', + ]); + + expect($transaction->transactionId)->toBe('txn_abc') + ->and($transaction->accountId)->toBe('acc_123') + ->and($transaction->amount)->toBe(42.50) + ->and($transaction->currencyCode)->toBe('USD') + ->and($transaction->name)->toBe('Whole Foods Market') + ->and($transaction->merchantName)->toBe('Whole Foods') + ->and($transaction->date)->toBeInstanceOf(Carbon::class) + ->and($transaction->date->toDateString())->toBe('2026-04-01') + ->and($transaction->authorizedAt)->toBeInstanceOf(Carbon::class) + ->and($transaction->category)->toBe(['Food and Drink', 'Groceries']) + ->and($transaction->categoryId)->toBe('cat_001') + ->and($transaction->paymentChannel)->toBe('in store') + ->and($transaction->pending)->toBeFalse() + ->and($transaction->logoUrl)->toBe('https://logo.example.com') + ->and($transaction->website)->toBe('wholefoodsmarket.com'); +}); + +it('handles missing optional fields', function () { + $transaction = Transaction::fromPlaid([ + 'transaction_id' => 'txn_min', + 'account_id' => 'acc_123', + 'amount' => 10.00, + 'iso_currency_code' => 'USD', + 'name' => 'ATM Withdrawal', + 'date' => '2026-04-01', + 'pending' => false, + ]); + + expect($transaction->merchantName)->toBeNull() + ->and($transaction->authorizedAt)->toBeNull() + ->and($transaction->category)->toBe([]) + ->and($transaction->categoryId)->toBeNull() + ->and($transaction->paymentChannel)->toBe('other') + ->and($transaction->logoUrl)->toBeNull() + ->and($transaction->website)->toBeNull(); +}); + +it('identifies debits correctly', function () { + $transaction = Transaction::fromPlaid([ + 'transaction_id' => 'txn_debit', + 'account_id' => 'acc_123', + 'amount' => 50.00, + 'iso_currency_code' => 'USD', + 'name' => 'Coffee Shop', + 'date' => '2026-04-01', + 'pending' => false, + ]); + + expect($transaction->isDebit())->toBeTrue() + ->and($transaction->isCredit())->toBeFalse(); +}); + +it('identifies credits correctly', function () { + $transaction = Transaction::fromPlaid([ + 'transaction_id' => 'txn_credit', + 'account_id' => 'acc_123', + 'amount' => -1500.00, + 'iso_currency_code' => 'USD', + 'name' => 'Direct Deposit', + 'date' => '2026-04-01', + 'pending' => false, + ]); + + expect($transaction->isCredit())->toBeTrue() + ->and($transaction->isDebit())->toBeFalse(); +}); diff --git a/tests/Unit/Plaid/PlaidConnectorTest.php b/tests/Unit/Plaid/PlaidConnectorTest.php new file mode 100644 index 0000000..c59cb48 --- /dev/null +++ b/tests/Unit/Plaid/PlaidConnectorTest.php @@ -0,0 +1,23 @@ +resolveBaseUrl())->toBe('https://sandbox.plaid.com'); +}); + +it('resolves development base url', function () { + $connector = new PlaidConnector('id', 'secret', 'development'); + expect($connector->resolveBaseUrl())->toBe('https://development.plaid.com'); +}); + +it('resolves production base url', function () { + $connector = new PlaidConnector('id', 'secret', 'production'); + expect($connector->resolveBaseUrl())->toBe('https://production.plaid.com'); +}); + +it('defaults to sandbox for unknown environment', function () { + $connector = new PlaidConnector('id', 'secret', 'unknown'); + expect($connector->resolveBaseUrl())->toBe('https://sandbox.plaid.com'); +}); diff --git a/tests/Unit/Plaid/Requests/CreateLinkTokenTest.php b/tests/Unit/Plaid/Requests/CreateLinkTokenTest.php new file mode 100644 index 0000000..7b8ce28 --- /dev/null +++ b/tests/Unit/Plaid/Requests/CreateLinkTokenTest.php @@ -0,0 +1,47 @@ +getMethod())->toBe(Method::POST); +}); + +it('resolves correct endpoint', function () { + $request = new CreateLinkToken('user_123'); + expect($request->resolveEndpoint())->toBe('/link/token/create'); +}); + +it('includes required body fields', function () { + $request = new CreateLinkToken('user_123'); + $body = $request->defaultBody(); + + expect($body['user'])->toBe(['client_user_id' => 'user_123']) + ->and($body['client_name'])->toBe('Finance') + ->and($body['products'])->toBe(['transactions']) + ->and($body['country_codes'])->toBe(['US']) + ->and($body['language'])->toBe('en'); +}); + +it('includes webhook when provided', function () { + $request = new CreateLinkToken('user_123', webhook: 'https://example.com/webhook'); + $body = $request->defaultBody(); + + expect($body['webhook'])->toBe('https://example.com/webhook'); +}); + +it('excludes webhook when null', function () { + $request = new CreateLinkToken('user_123'); + $body = $request->defaultBody(); + + expect($body)->not->toHaveKey('webhook'); +}); + +it('accepts custom products and country codes', function () { + $request = new CreateLinkToken('user_123', ['auth', 'transactions'], ['CA']); + $body = $request->defaultBody(); + + expect($body['products'])->toBe(['auth', 'transactions']) + ->and($body['country_codes'])->toBe(['CA']); +}); diff --git a/tests/Unit/Plaid/Requests/ExchangePublicTokenTest.php b/tests/Unit/Plaid/Requests/ExchangePublicTokenTest.php new file mode 100644 index 0000000..bdc55bd --- /dev/null +++ b/tests/Unit/Plaid/Requests/ExchangePublicTokenTest.php @@ -0,0 +1,21 @@ +getMethod())->toBe(Method::POST); +}); + +it('resolves correct endpoint', function () { + $request = new ExchangePublicToken('public-token-sandbox-abc'); + expect($request->resolveEndpoint())->toBe('/item/public_token/exchange'); +}); + +it('includes public token in body', function () { + $request = new ExchangePublicToken('public-token-sandbox-abc'); + $body = $request->defaultBody(); + + expect($body['public_token'])->toBe('public-token-sandbox-abc'); +}); diff --git a/tests/Unit/Plaid/Requests/GetAccountsTest.php b/tests/Unit/Plaid/Requests/GetAccountsTest.php new file mode 100644 index 0000000..ba09076 --- /dev/null +++ b/tests/Unit/Plaid/Requests/GetAccountsTest.php @@ -0,0 +1,21 @@ +getMethod())->toBe(Method::POST); +}); + +it('resolves correct endpoint', function () { + $request = new GetAccounts('access-sandbox-token'); + expect($request->resolveEndpoint())->toBe('/accounts/get'); +}); + +it('includes access token in body', function () { + $request = new GetAccounts('access-sandbox-token'); + $body = $request->defaultBody(); + + expect($body['access_token'])->toBe('access-sandbox-token'); +}); diff --git a/tests/Unit/Plaid/Requests/SyncTransactionsTest.php b/tests/Unit/Plaid/Requests/SyncTransactionsTest.php new file mode 100644 index 0000000..fd4e2b8 --- /dev/null +++ b/tests/Unit/Plaid/Requests/SyncTransactionsTest.php @@ -0,0 +1,37 @@ +getMethod())->toBe(Method::POST); +}); + +it('resolves correct endpoint', function () { + $request = new SyncTransactions('access-token'); + expect($request->resolveEndpoint())->toBe('/transactions/sync'); +}); + +it('includes access token and default count in body', function () { + $request = new SyncTransactions('access-token'); + $body = $request->defaultBody(); + + expect($body['access_token'])->toBe('access-token') + ->and($body['count'])->toBe(100) + ->and($body)->not->toHaveKey('cursor'); +}); + +it('includes cursor when provided', function () { + $request = new SyncTransactions('access-token', 'cursor_xyz'); + $body = $request->defaultBody(); + + expect($body['cursor'])->toBe('cursor_xyz'); +}); + +it('accepts custom count', function () { + $request = new SyncTransactions('access-token', null, 250); + $body = $request->defaultBody(); + + expect($body['count'])->toBe(250); +}); diff --git a/tests/Unit/Privacy/AmountBucketerTest.php b/tests/Unit/Privacy/AmountBucketerTest.php new file mode 100644 index 0000000..2a12511 --- /dev/null +++ b/tests/Unit/Privacy/AmountBucketerTest.php @@ -0,0 +1,38 @@ +bucket(5.00))->toBe('under $10') + ->and($bucketer->bucket(9.99))->toBe('under $10'); +}); + +it('buckets mid-range amounts correctly', function () { + $bucketer = new AmountBucketer; + + expect($bucketer->bucket(50.00))->toBe('$50–$100') + ->and($bucketer->bucket(99.99))->toBe('$50–$100') + ->and($bucketer->bucket(100.00))->toBe('$100–$250'); +}); + +it('handles exact bucket boundaries', function () { + $bucketer = new AmountBucketer; + + expect($bucketer->bucket(10.00))->toBe('$10–$25') + ->and($bucketer->bucket(25.00))->toBe('$25–$50') + ->and($bucketer->bucket(500.00))->toBe('$500–$1,000'); +}); + +it('buckets large amounts correctly', function () { + $bucketer = new AmountBucketer; + + expect($bucketer->bucket(15000.00))->toBe('over $10,000'); +}); + +it('handles negative amounts (credits) by absolute value', function () { + $bucketer = new AmountBucketer; + + expect($bucketer->bucket(-75.00))->toBe('$50–$100'); +}); diff --git a/tests/Unit/Privacy/CloudPayloadTest.php b/tests/Unit/Privacy/CloudPayloadTest.php new file mode 100644 index 0000000..115fbf7 --- /dev/null +++ b/tests/Unit/Privacy/CloudPayloadTest.php @@ -0,0 +1,51 @@ + ['count' => 5]], + ); + + expect($payload->task)->toBe('spending summary') + ->and($payload->data)->toBe(['Groceries' => ['count' => 5]]) + ->and($payload->meta)->toBe([]); +}); + +it('stores optional meta', function () { + $payload = new CloudPayload( + task: 'cashflow', + data: [], + meta: ['fallback' => true], + ); + + expect($payload->meta)->toBe(['fallback' => true]); +}); + +it('converts to array', function () { + $payload = new CloudPayload( + task: 'spending summary', + data: ['key' => 'value'], + meta: ['source' => 'ollama'], + ); + + expect($payload->toArray())->toBe([ + 'task' => 'spending summary', + 'data' => ['key' => 'value'], + 'meta' => ['source' => 'ollama'], + ]); +}); + +it('converts to json', function () { + $payload = new CloudPayload( + task: 'balances', + data: ['checking' => '$1,000'], + ); + + $json = $payload->toJson(); + $decoded = json_decode($json, true); + + expect($decoded['task'])->toBe('balances') + ->and($decoded['data']['checking'])->toBe('$1,000'); +}); diff --git a/tests/Unit/Privacy/PrivacyTransformerTest.php b/tests/Unit/Privacy/PrivacyTransformerTest.php new file mode 100644 index 0000000..a4a3206 --- /dev/null +++ b/tests/Unit/Privacy/PrivacyTransformerTest.php @@ -0,0 +1,156 @@ + 'txn_'.uniqid(), + 'account_id' => 'acc_123', + 'amount' => 75.00, + 'iso_currency_code' => 'USD', + 'name' => 'Whole Foods', + 'merchant_name' => 'Whole Foods', + 'date' => '2026-04-01', + 'category' => ['Food and Drink', 'Groceries'], + 'payment_channel' => 'in store', + 'pending' => false, + ], $overrides)); +} + +it('returns CloudPayload in passthrough mode', function () { + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'passthrough', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([makeTransaction()]); + $payload = $transformer->prepare($transactions, 'spending summary'); + + expect($payload)->toBeInstanceOf(CloudPayload::class) + ->and($payload->task)->toBe('spending summary') + ->and($payload->data)->toBeArray() + ->and($payload->data[0])->toHaveKeys(['amount', 'type', 'category', 'channel', 'month', 'pending']); +}); + +it('rule-strips exact amounts to buckets', function () { + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'passthrough', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([makeTransaction(['amount' => 75.00])]); + $payload = $transformer->prepare($transactions, 'test'); + + expect($payload->data[0]['amount'])->toBe('$50–$100'); +}); + +it('labels debits and credits correctly', function () { + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'passthrough', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([ + makeTransaction(['amount' => 50.00]), + makeTransaction(['amount' => -1500.00]), + ]); + + $payload = $transformer->prepare($transactions, 'test'); + + expect($payload->data[0]['type'])->toBe('debit') + ->and($payload->data[1]['type'])->toBe('credit'); +}); + +it('drops day from date, keeps year-month', function () { + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'passthrough', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([makeTransaction(['date' => '2026-04-15'])]); + $payload = $transformer->prepare($transactions, 'test'); + + expect($payload->data[0]['month'])->toBe('2026-04'); +}); + +it('falls back to Uncategorized when category is empty', function () { + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'passthrough', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([makeTransaction(['category' => []])]); + $payload = $transformer->prepare($transactions, 'test'); + + expect($payload->data[0]['category'])->toBe('Uncategorized'); +}); + +it('uses most specific plaid category level', function () { + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'passthrough', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([makeTransaction(['category' => ['Food and Drink', 'Restaurants', 'Fast Food']])]); + $payload = $transformer->prepare($transactions, 'test'); + + expect($payload->data[0]['category'])->toBe('Fast Food'); +}); + +it('calls ollama and returns reduced payload', function () { + $fake = Prism::fake([ + TextResponseFake::make()->withText('{"Groceries":{"count":3,"range":"$50-$100"}}'), + ]); + + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'ollama', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([makeTransaction()]); + $payload = $transformer->prepare($transactions, 'spending by category'); + + expect($payload)->toBeInstanceOf(CloudPayload::class) + ->and($payload->data)->toHaveKey('Groceries'); +}); + +it('falls back to rule-stripped data when ollama returns invalid json', function () { + Prism::fake([ + TextResponseFake::make()->withText('this is not json at all'), + ]); + + $transformer = new PrivacyTransformer( + bucketer: new AmountBucketer, + driver: 'ollama', + model: 'llama3.2', + endpoint: 'http://localhost:11434', + ); + + $transactions = collect([makeTransaction()]); + $payload = $transformer->prepare($transactions, 'spending by category'); + + expect($payload->meta)->toHaveKey('fallback') + ->and($payload->meta['fallback'])->toBeTrue() + ->and($payload->data)->toBeArray(); +}); diff --git a/tests/Unit/Providers/PlaidProviderTest.php b/tests/Unit/Providers/PlaidProviderTest.php new file mode 100644 index 0000000..9f72709 --- /dev/null +++ b/tests/Unit/Providers/PlaidProviderTest.php @@ -0,0 +1,164 @@ + $id, + 'name' => 'Checking', + 'official_name' => 'Premier Checking', + 'type' => 'depository', + 'subtype' => 'checking', + 'balances' => [ + 'available' => 1000.00, + 'current' => 1100.00, + 'limit' => null, + 'iso_currency_code' => 'USD', + ], + ]; +} + +function makePlaidTransaction(string $id = 'txn_1', string $date = '2026-04-01'): array +{ + return [ + 'transaction_id' => $id, + 'account_id' => 'acc_1', + 'amount' => 42.50, + 'iso_currency_code' => 'USD', + 'name' => 'Coffee Shop', + 'date' => $date, + 'category' => ['Food and Drink'], + 'payment_channel' => 'in store', + 'pending' => false, + ]; +} + +it('returns typed account collection', function () { + $mockClient = new MockClient([ + GetAccounts::class => MockResponse::make([ + 'accounts' => [makePlaidAccount('acc_1'), makePlaidAccount('acc_2')], + ]), + ]); + + $connector = new PlaidConnector('id', 'secret', 'sandbox'); + $connector->withMockClient($mockClient); + + $provider = new PlaidProvider($connector, 'access-token'); + $accounts = $provider->accounts(); + + expect($accounts)->toHaveCount(2) + ->and($accounts->first())->toBeInstanceOf(Account::class) + ->and($accounts->first()->accountId)->toBe('acc_1'); +}); + +it('returns sync result with typed collections', function () { + $mockClient = new MockClient([ + SyncTransactions::class => MockResponse::make([ + 'added' => [makePlaidTransaction('txn_1')], + 'modified' => [], + 'removed' => [], + 'next_cursor' => 'cursor_next', + 'has_more' => false, + ]), + ]); + + $connector = new PlaidConnector('id', 'secret', 'sandbox'); + $connector->withMockClient($mockClient); + + $provider = new PlaidProvider($connector, 'access-token'); + $result = $provider->sync(); + + expect($result)->toBeInstanceOf(SyncResult::class) + ->and($result->added)->toHaveCount(1) + ->and($result->added->first())->toBeInstanceOf(Transaction::class) + ->and($result->nextCursor)->toBe('cursor_next') + ->and($result->hasMore)->toBeFalse(); +}); + +it('passes cursor to sync request', function () { + $mockClient = new MockClient([ + SyncTransactions::class => MockResponse::make([ + 'added' => [], + 'modified' => [], + 'removed' => [], + 'next_cursor' => 'cursor_2', + 'has_more' => false, + ]), + ]); + + $connector = new PlaidConnector('id', 'secret', 'sandbox'); + $connector->withMockClient($mockClient); + + $provider = new PlaidProvider($connector, 'access-token'); + $result = $provider->sync('cursor_1'); + + expect($result->nextCursor)->toBe('cursor_2'); +}); + +it('filters transactions by date range', function () { + $mockClient = new MockClient([ + SyncTransactions::class => MockResponse::make([ + 'added' => [ + makePlaidTransaction('txn_april', '2026-04-05'), + makePlaidTransaction('txn_march', '2026-03-05'), + ], + 'modified' => [], + 'removed' => [], + 'next_cursor' => 'done', + 'has_more' => false, + ]), + ]); + + $connector = new PlaidConnector('id', 'secret', 'sandbox'); + $connector->withMockClient($mockClient); + + $provider = new PlaidProvider($connector, 'access-token'); + $transactions = $provider->transactions( + Carbon\Carbon::parse('2026-04-01'), + Carbon\Carbon::parse('2026-04-30'), + ); + + expect($transactions)->toHaveCount(1) + ->and($transactions->first()->transactionId)->toBe('txn_april'); +}); + +it('paginates through all pages when has_more is true', function () { + $mockClient = new MockClient([ + SyncTransactions::class => MockResponse::makeSequence([ + MockResponse::make([ + 'added' => [makePlaidTransaction('txn_page1', '2026-04-01')], + 'modified' => [], + 'removed' => [], + 'next_cursor' => 'cursor_page2', + 'has_more' => true, + ]), + MockResponse::make([ + 'added' => [makePlaidTransaction('txn_page2', '2026-04-02')], + 'modified' => [], + 'removed' => [], + 'next_cursor' => 'cursor_done', + 'has_more' => false, + ]), + ]), + ]); + + $connector = new PlaidConnector('id', 'secret', 'sandbox'); + $connector->withMockClient($mockClient); + + $provider = new PlaidProvider($connector, 'access-token'); + $transactions = $provider->transactions( + Carbon\Carbon::parse('2026-04-01'), + Carbon\Carbon::parse('2026-04-30'), + ); + + expect($transactions)->toHaveCount(2); +}); diff --git a/tests/Unit/Tools/FinanceToolsTest.php b/tests/Unit/Tools/FinanceToolsTest.php new file mode 100644 index 0000000..456d103 --- /dev/null +++ b/tests/Unit/Tools/FinanceToolsTest.php @@ -0,0 +1,31 @@ +mockProvider = Mockery::mock(FinanceDataProvider::class); + $this->mockTransformer = Mockery::mock(PrivacyTransformer::class); + + app()->instance(FinanceDataProvider::class, $this->mockProvider); + app()->instance(PrivacyTransformer::class, $this->mockTransformer); +}); + +it('all() returns three Tool instances', function () { + $tools = FinanceTools::all(); + + expect($tools)->toHaveCount(3) + ->and($tools[0])->toBeInstanceOf(Tool::class) + ->and($tools[1])->toBeInstanceOf(Tool::class) + ->and($tools[2])->toBeInstanceOf(Tool::class); +}); + +it('spending() returns two Tool instances', function () { + $tools = FinanceTools::spending(); + + expect($tools)->toHaveCount(2) + ->and($tools[0])->toBeInstanceOf(Tool::class) + ->and($tools[1])->toBeInstanceOf(Tool::class); +}); diff --git a/tests/Unit/Tools/GetAccountBalancesToolTest.php b/tests/Unit/Tools/GetAccountBalancesToolTest.php new file mode 100644 index 0000000..47051b7 --- /dev/null +++ b/tests/Unit/Tools/GetAccountBalancesToolTest.php @@ -0,0 +1,54 @@ +mockAccounts = collect([ + new Account('acc_1', 'Checking', 'Premier Checking', 'depository', 'checking', 1500.00, 1600.00, null, 'USD'), + new Account('acc_2', 'Visa', 'Visa Signature', 'credit', 'credit card', 4000.00, 1000.00, 5000.00, 'USD'), + ]); + + $this->mockProvider = Mockery::mock(FinanceDataProvider::class); + app()->instance(FinanceDataProvider::class, $this->mockProvider); +}); + +it('returns a Tool instance with correct name', function () { + $this->mockProvider->shouldReceive('accounts')->andReturn($this->mockAccounts); + + $tool = GetAccountBalancesTool::make(); + + expect($tool)->toBeInstanceOf(Tool::class) + ->and($tool->name())->toBe('get_account_balances'); +}); + +it('returns json with rounded balances', function () { + $this->mockProvider->shouldReceive('accounts')->andReturn($this->mockAccounts); + + $tool = GetAccountBalancesTool::make(); + $result = $tool->handle(); + $data = json_decode($result, true); + + expect($data)->toHaveCount(2) + ->and($data[0]['type'])->toBe('depository') + ->and($data[0]['subtype'])->toBe('checking') + ->and($data[0]['current'])->toContain('$') // rounded, formatted + ->and($data[1]['limit'])->toContain('$5,000'); +}); + +it('handles null available balance', function () { + $accounts = collect([ + new Account('acc_1', 'Savings', 'Savings', 'depository', 'savings', null, 5000.00, null, 'USD'), + ]); + + $this->mockProvider->shouldReceive('accounts')->andReturn($accounts); + + $tool = GetAccountBalancesTool::make(); + $result = $tool->handle(); + $data = json_decode($result, true); + + expect($data[0]['available'])->toBeNull() + ->and($data[0]['limit'])->toBeNull(); +}); diff --git a/tests/Unit/Tools/GetCashflowToolTest.php b/tests/Unit/Tools/GetCashflowToolTest.php new file mode 100644 index 0000000..a8c9f90 --- /dev/null +++ b/tests/Unit/Tools/GetCashflowToolTest.php @@ -0,0 +1,86 @@ +mockProvider = Mockery::mock(FinanceDataProvider::class); + $this->mockTransformer = Mockery::mock(PrivacyTransformer::class); + + app()->instance(FinanceDataProvider::class, $this->mockProvider); + app()->instance(PrivacyTransformer::class, $this->mockTransformer); +}); + +afterEach(fn () => Carbon::setTestNow()); + +it('returns a Tool instance with correct name', function () { + $this->mockProvider->shouldReceive('transactions')->andReturn(collect()); + $this->mockTransformer->shouldReceive('prepare')->andReturn( + new CloudPayload('cashflow', []) + ); + + $tool = GetCashflowTool::make(); + + expect($tool)->toBeInstanceOf(Tool::class) + ->and($tool->name())->toBe('get_cashflow'); +}); + +it('returns json from cloud payload', function () { + $this->mockProvider->shouldReceive('transactions')->andReturn(collect()); + $this->mockTransformer->shouldReceive('prepare')->andReturn( + new CloudPayload('cashflow', ['net' => '$500-$1,000']) + ); + + $tool = GetCashflowTool::make(); + $result = $tool->handle('last_month'); + + expect($result)->toBeString() + ->and(json_decode($result, true))->toHaveKey('data'); +}); + +it('uses last_month period correctly', function () { + $this->mockProvider + ->shouldReceive('transactions') + ->withArgs(fn ($from, $to) => + $from->toDateString() === '2026-03-01' && + $to->toDateString() === '2026-03-31' + ) + ->andReturn(collect()); + + $this->mockTransformer->shouldReceive('prepare')->andReturn( + new CloudPayload('cashflow', []) + ); + + $tool = GetCashflowTool::make(); + $tool->handle('last_month'); +}); + +it('filters pending transactions', function () { + $transactions = collect([ + \TheShit\Finance\Plaid\DTOs\Transaction::fromPlaid([ + 'transaction_id' => 'txn_pending', + 'account_id' => 'acc_1', + 'amount' => 100.00, + 'iso_currency_code' => 'USD', + 'name' => 'Pending', + 'date' => '2026-04-10', + 'payment_channel' => 'online', + 'pending' => true, + ]), + ]); + + $this->mockProvider->shouldReceive('transactions')->andReturn($transactions); + $this->mockTransformer + ->shouldReceive('prepare') + ->withArgs(fn ($txns) => $txns->isEmpty()) + ->andReturn(new CloudPayload('cashflow', [])); + + $tool = GetCashflowTool::make(); + $tool->handle('current_month'); +}); diff --git a/tests/Unit/Tools/GetSpendingSummaryToolTest.php b/tests/Unit/Tools/GetSpendingSummaryToolTest.php new file mode 100644 index 0000000..9d9a049 --- /dev/null +++ b/tests/Unit/Tools/GetSpendingSummaryToolTest.php @@ -0,0 +1,103 @@ +mockTransactions = collect([ + Transaction::fromPlaid([ + 'transaction_id' => 'txn_1', + 'account_id' => 'acc_1', + 'amount' => 50.00, + 'iso_currency_code' => 'USD', + 'name' => 'Grocery Store', + 'date' => '2026-04-05', + 'category' => ['Food and Drink', 'Groceries'], + 'payment_channel' => 'in store', + 'pending' => false, + ]), + ]); + + $this->mockProvider = Mockery::mock(FinanceDataProvider::class); + $this->mockTransformer = Mockery::mock(PrivacyTransformer::class); + + app()->instance(FinanceDataProvider::class, $this->mockProvider); + app()->instance(PrivacyTransformer::class, $this->mockTransformer); +}); + +afterEach(fn () => Carbon::setTestNow()); + +it('returns a Tool instance with correct name', function () { + $this->mockProvider->shouldReceive('transactions')->andReturn($this->mockTransactions); + $this->mockTransformer->shouldReceive('prepare')->andReturn( + new CloudPayload('test', ['Groceries' => 3]) + ); + + $tool = GetSpendingSummaryTool::make(); + + expect($tool)->toBeInstanceOf(Tool::class) + ->and($tool->name())->toBe('get_spending_summary'); +}); + +it('filters pending transactions before sending to transformer', function () { + $withPending = collect([ + Transaction::fromPlaid([ + 'transaction_id' => 'txn_pending', + 'account_id' => 'acc_1', + 'amount' => 30.00, + 'iso_currency_code' => 'USD', + 'name' => 'Pending Charge', + 'date' => '2026-04-10', + 'category' => ['Shopping'], + 'payment_channel' => 'online', + 'pending' => true, + ]), + ...$this->mockTransactions, + ]); + + $this->mockProvider->shouldReceive('transactions')->andReturn($withPending); + $this->mockTransformer + ->shouldReceive('prepare') + ->withArgs(fn ($txns) => $txns->count() === 1) // pending filtered out + ->andReturn(new CloudPayload('test', [])); + + $tool = GetSpendingSummaryTool::make(); + $tool->handle('current_month'); +}); + +it('returns json string from cloud payload', function () { + $this->mockProvider->shouldReceive('transactions')->andReturn($this->mockTransactions); + $this->mockTransformer->shouldReceive('prepare')->andReturn( + new CloudPayload('spending summary', ['Groceries' => ['count' => 1]]) + ); + + $tool = GetSpendingSummaryTool::make(); + $result = $tool->handle('current_month'); + + expect($result)->toBeString() + ->and(json_decode($result, true))->toHaveKey('data'); +}); + +it('defaults to current_month when no period given', function () { + $this->mockProvider + ->shouldReceive('transactions') + ->withArgs(fn ($from, $to) => + $from->toDateString() === '2026-04-01' && + $to->toDateString() === '2026-04-15' + ) + ->andReturn(collect()); + + $this->mockTransformer->shouldReceive('prepare')->andReturn( + new CloudPayload('test', []) + ); + + $tool = GetSpendingSummaryTool::make(); + $tool->handle(); +}); diff --git a/tests/Unit/Tools/ResolvePeriodTest.php b/tests/Unit/Tools/ResolvePeriodTest.php new file mode 100644 index 0000000..3ffe04d --- /dev/null +++ b/tests/Unit/Tools/ResolvePeriodTest.php @@ -0,0 +1,39 @@ + Carbon::setTestNow('2026-04-15 12:00:00')); +afterEach(fn () => Carbon::setTestNow()); + +it('resolves current_month to start of month through today', function () { + // Use reflection to test the protected trait method + $method = new ReflectionMethod(GetSpendingSummaryTool::class, 'resolvePeriod'); + $method->setAccessible(true); + + [$from, $to] = $method->invoke(null, 'current_month'); + + expect($from->toDateString())->toBe('2026-04-01') + ->and($to->toDateString())->toBe('2026-04-15'); +}); + +it('resolves last_month to the full previous month', function () { + $method = new ReflectionMethod(GetSpendingSummaryTool::class, 'resolvePeriod'); + $method->setAccessible(true); + + [$from, $to] = $method->invoke(null, 'last_month'); + + expect($from->toDateString())->toBe('2026-03-01') + ->and($to->toDateString())->toBe('2026-03-31'); +}); + +it('resolves last_30_days to a rolling window', function () { + $method = new ReflectionMethod(GetSpendingSummaryTool::class, 'resolvePeriod'); + $method->setAccessible(true); + + [$from, $to] = $method->invoke(null, 'last_30_days'); + + expect($from->toDateString())->toBe('2026-03-16') + ->and($to->toDateString())->toBe('2026-04-15'); +});