Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
@@ -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 }}
19 changes: 16 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
11 changes: 6 additions & 5 deletions config/finance.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
8 changes: 8 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
includes:
- vendor/larastan/larastan/extension.neon

parameters:
paths:
- src
level: 8
checkMissingIterableValueType: false
22 changes: 22 additions & 0 deletions phpunit.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory>src</directory>
</include>
</coverage>
<php>
<env name="APP_ENV" value="testing"/>
</php>
</phpunit>
7 changes: 7 additions & 0 deletions pint.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"preset": "laravel",
"rules": {
"ordered_imports": { "sort_algorithm": "alpha" },
"no_unused_imports": true
}
}
9 changes: 9 additions & 0 deletions src/FinanceServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/Privacy/PrivacyTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
31 changes: 31 additions & 0 deletions src/Tools/Concerns/ResolvePeriod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace TheShit\Finance\Tools\Concerns;

use Carbon\Carbon;

trait ResolvePeriod
{
/**
* Resolve a period string into a [from, to] Carbon pair.
*
* @return array{0: Carbon, 1: Carbon}
*/
protected static function resolvePeriod(string $period): array
{
return match ($period) {
'last_month' => [
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(),
],
};
}
}
42 changes: 42 additions & 0 deletions src/Tools/FinanceTools.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace TheShit\Finance\Tools;

use Prism\Prism\Tool;

/**
* Registry of all available finance tools.
*
* Usage:
* $agent->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(),
];
}
}
36 changes: 36 additions & 0 deletions src/Tools/GetAccountBalancesTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace TheShit\Finance\Tools;

use Prism\Prism\Tool;
use TheShit\Finance\Contracts\FinanceDataProvider;

class GetAccountBalancesTool
{
public static function make(): Tool
{
$provider = app(FinanceDataProvider::class);

return Tool::as('get_account_balances')
->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);
});
}
}
40 changes: 40 additions & 0 deletions src/Tools/GetCashflowTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace TheShit\Finance\Tools;

use Prism\Prism\Tool;
use TheShit\Finance\Contracts\FinanceDataProvider;
use TheShit\Finance\Privacy\PrivacyTransformer;
use TheShit\Finance\Tools\Concerns\ResolvePeriod;

class GetCashflowTool
{
use ResolvePeriod;

public static function make(): Tool
{
$provider = app(FinanceDataProvider::class);
$transformer = app(PrivacyTransformer::class);

return Tool::as('get_cashflow')
->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();
});
}
}
40 changes: 40 additions & 0 deletions src/Tools/GetSpendingSummaryTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace TheShit\Finance\Tools;

use Prism\Prism\Tool;
use TheShit\Finance\Contracts\FinanceDataProvider;
use TheShit\Finance\Privacy\PrivacyTransformer;
use TheShit\Finance\Tools\Concerns\ResolvePeriod;

class GetSpendingSummaryTool
{
use ResolvePeriod;

public static function make(): Tool
{
$provider = app(FinanceDataProvider::class);
$transformer = app(PrivacyTransformer::class);

return Tool::as('get_spending_summary')
->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();
});
}
}
Loading
Loading