Compare commits
46 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
8172ca78d7 | |
|
|
2cca291773 | |
|
|
4c83cb8b33 | |
|
|
2b2e4c4a87 | |
|
|
52aae3fd67 | |
|
|
89174af4e6 | |
|
|
2d7e905ddd | |
|
|
5621e90879 | |
|
|
41ac3e90e1 | |
|
|
6d5f440727 | |
|
|
5d7b655111 | |
|
|
fb1adfc484 | |
|
|
782ec2f2e9 | |
|
|
4cd7619791 | |
|
|
808d41eee2 | |
|
|
7aa32dab0f | |
|
|
a873582191 | |
|
|
9312d24f93 | |
|
|
c7719fe5ad | |
|
|
6490729fcc | |
|
|
3db03de86d | |
|
|
d7963736d1 | |
|
|
9e237802f6 | |
|
|
55eff788e8 | |
|
|
19e0c24f9a | |
|
|
91ffeb917d | |
|
|
b9b507005f | |
|
|
cbeea2fc34 | |
|
|
c0816f9633 | |
|
|
7281b610b5 | |
|
|
cd918523e8 | |
|
|
dada23cd84 | |
|
|
815ca6244c | |
|
|
ca2e5c09b3 | |
|
|
6f72c43cce | |
|
|
abe31ff967 | |
|
|
08d367a5c8 | |
|
|
1a62b8f42e | |
|
|
e339aaa193 | |
|
|
5b3400909e | |
|
|
7d750fa1a8 | |
|
|
d504e70309 | |
|
|
9b7632f585 | |
|
|
a38dc5dd95 | |
|
|
3741f11584 | |
|
|
362ac445ea |
|
|
@ -8,6 +8,12 @@ APP_LOCALE=en
|
|||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
# Passport OAuth signing keys for the MCP OAuth server. Leave blank to use the
|
||||
# keys generated by `php artisan passport:keys` under storage/. Set both (PEM
|
||||
# contents) to share one key pair across multiple app instances.
|
||||
PASSPORT_PRIVATE_KEY=
|
||||
PASSPORT_PUBLIC_KEY=
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ jobs:
|
|||
- name: Generate Application Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Generate Passport Keys
|
||||
run: php artisan passport:keys --force
|
||||
|
||||
- name: Tests
|
||||
# --parallel splits the suite across workers via paratest. Each
|
||||
# worker creates its own "testing_test_<N>" database; the mysql
|
||||
|
|
@ -144,6 +147,9 @@ jobs:
|
|||
- name: Generate Application Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Generate Passport Keys
|
||||
run: php artisan passport:keys --force
|
||||
|
||||
- name: Browser Tests
|
||||
run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6
|
||||
env:
|
||||
|
|
@ -235,6 +241,9 @@ jobs:
|
|||
|
||||
linter:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -273,6 +282,33 @@ jobs:
|
|||
- name: Frontend Tests
|
||||
run: bun run test
|
||||
|
||||
# Gates merges on a Conventional-Commit PR title. Lives in the linter
|
||||
# job (a required check) so a bad title blocks the merge, unlike the old
|
||||
# standalone workflow whose check wasn't required. Skipped on push since
|
||||
# there's no PR title to validate then.
|
||||
- name: Conventional PR title
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
chore
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
revert
|
||||
requireScope: false
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
subjectPatternError: |
|
||||
Subject must not start with uppercase. Use: feat: add checkout, fix(billing): handle retry.
|
||||
|
||||
performance-tests:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
name: PR title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
semantic-pr-title:
|
||||
name: Conventional PR title
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
chore
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
revert
|
||||
requireScope: false
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
subjectPatternError: |
|
||||
Subject must not start with uppercase. Use: feat: add checkout, fix(billing): handle retry.
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * 1" # Mondays 08:00 UTC = 10:00 Madrid (CEST) / 09:00 (CET)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
increment:
|
||||
|
|
@ -42,23 +44,38 @@ jobs:
|
|||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Check for releasable commits
|
||||
id: guard
|
||||
run: |
|
||||
LAST=$(git describe --tags --abbrev=0)
|
||||
if [ -z "$(git log "$LAST"..HEAD --oneline)" ]; then
|
||||
echo "No commits since $LAST; skipping release."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create release branch
|
||||
id: branch
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
run: |
|
||||
BRANCH="release/run-${{ github.run_id }}"
|
||||
git checkout -b "$BRANCH"
|
||||
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run release-it
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx release-it ${{ inputs.increment }} --ci
|
||||
run: npx release-it "${{ inputs.increment || 'patch' }}" --ci
|
||||
|
||||
- name: Read new version
|
||||
id: version
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
run: echo "value=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Open pull request
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ composer run dev # Start full dev environment (PHP server, queue, Vite,
|
|||
bun run dev # Vite dev server only
|
||||
```
|
||||
|
||||
### Running the server for QA
|
||||
|
||||
When a command or skill needs the user to open the app in Chrome to review a change:
|
||||
|
||||
1. **First run in a worktree?** Prepare it: `./worktree.sh /path/to/main/repo` (copies `.env` + `storage/keys`, installs `bun` and `composer` deps). Skip if already set up.
|
||||
2. **Start the server** if it isn't running: `composer run dev`.
|
||||
3. **Get the URL** — it's dynamic per worktree, so don't guess it. On startup `composer run dev` prints and copies it to the clipboard (`✓ <URL> copied to clipboard`); read it from the `server` pane output. Ask the user to open that URL in Chrome to QA.
|
||||
|
||||
### Build & Quality
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ The template includes:
|
|||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#whisper-money/whisper-money&type=date&legend=top-left)
|
||||
[](https://www.star-history.com/?type=date&legend=top-left&repos=whisper-money%2Fwhisper-money)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Actions;
|
|||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -13,14 +14,16 @@ class CreateDefaultCategories
|
|||
{
|
||||
/**
|
||||
* Create default categories for a newly registered user, nesting child
|
||||
* categories under their configured parent.
|
||||
* categories under their configured parent. Categories live in a space, so
|
||||
* seeding targets the given space (defaulting to the user's active one).
|
||||
*/
|
||||
public function handle(User $user): void
|
||||
public function handle(User $user, ?Space $space = null): void
|
||||
{
|
||||
$space ??= $user->activeSpace();
|
||||
$locale = $user->locale ?? app()->getLocale();
|
||||
$defaultCategories = self::getDefaultCategories($locale);
|
||||
|
||||
$existingCategories = $user->categories()
|
||||
$existingCategories = $space->categories()
|
||||
->whereIn('name', array_column($defaultCategories, 'name'))
|
||||
->pluck('id', 'name');
|
||||
|
||||
|
|
@ -32,6 +35,7 @@ class CreateDefaultCategories
|
|||
'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value,
|
||||
'id' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\SavedFilter;
|
||||
use App\Models\Space;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillSpaces extends Command
|
||||
{
|
||||
protected $signature = 'spaces:backfill {--chunk=500 : Users processed per batch}';
|
||||
|
||||
protected $description = 'Provision a personal space for every user and stamp their existing rows with it';
|
||||
|
||||
/**
|
||||
* Owned tables to backfill. Each row inherits the personal space of its
|
||||
* user_id. Idempotent: only rows with a null space_id are touched.
|
||||
*
|
||||
* @var list<class-string<Model>>
|
||||
*/
|
||||
private array $models = [
|
||||
Account::class,
|
||||
BankingConnection::class,
|
||||
Transaction::class,
|
||||
Category::class,
|
||||
Label::class,
|
||||
Budget::class,
|
||||
AutomationRule::class,
|
||||
SavedFilter::class,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
|
||||
// Soft-deleted users are included: their accounts/transactions still
|
||||
// exist and must be stamped too, so a restored account keeps its data
|
||||
// and the column can eventually go NOT NULL.
|
||||
$this->info('Provisioning personal spaces…');
|
||||
User::withTrashed()->whereNull('current_space_id')->chunkById($chunk, function ($users): void {
|
||||
foreach ($users as $user) {
|
||||
$user->provisionPersonalSpace();
|
||||
}
|
||||
});
|
||||
|
||||
$this->info('Backfilling space_id on owned rows…');
|
||||
Space::query()->where('personal', true)->chunkById($chunk, function ($spaces): void {
|
||||
foreach ($spaces as $space) {
|
||||
foreach ($this->models as $model) {
|
||||
$this->stamp($model, $space->owner_id, $space->id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<Model> $model
|
||||
*/
|
||||
private function stamp(string $model, string $ownerId, string $spaceId): void
|
||||
{
|
||||
// Go through the query builder rather than Eloquent so soft-deleted rows
|
||||
// are stamped too (no soft-delete global scope to work around).
|
||||
DB::table((new $model)->getTable())
|
||||
->whereNull('space_id')
|
||||
->where('user_id', $ownerId)
|
||||
->update(['space_id' => $spaceId]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Console\Commands\Concerns;
|
|||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
trait FindsUsersWithLegacyEncryption
|
||||
{
|
||||
|
|
@ -11,11 +12,14 @@ trait FindsUsersWithLegacyEncryption
|
|||
* Users who still have at least one client-side encrypted transaction or account
|
||||
* (a non-null `*_iv` column is the source of truth, not the stale `accounts.encrypted` flag).
|
||||
*
|
||||
* Subscriptions are eager-loaded so callers can filter with {@see excludeBilledUsers()}
|
||||
* without an N+1 query.
|
||||
*
|
||||
* @return Builder<User>
|
||||
*/
|
||||
protected function usersWithLegacyEncryption(): Builder
|
||||
{
|
||||
return User::query()->where(function (Builder $query): void {
|
||||
return User::query()->with('subscriptions')->where(function (Builder $query): void {
|
||||
$query->whereHas('transactions', function (Builder $transactions): void {
|
||||
$transactions->whereNotNull('description_iv')
|
||||
->orWhereNotNull('notes_iv');
|
||||
|
|
@ -24,4 +28,16 @@ trait FindsUsersWithLegacyEncryption
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop users who are still being billed. These accounts must never be emailed a
|
||||
* deletion warning nor deleted while a subscription or trial is active.
|
||||
*
|
||||
* @param Collection<int, User> $users
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
protected function excludeBilledUsers(Collection $users): Collection
|
||||
{
|
||||
return $users->reject(fn (User $user): bool => $user->hasActiveSubscriptionOrTrial())->values();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,13 +36,15 @@ class DeleteEncryptedDataAccountsCommand extends Command
|
|||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$users = $this->usersWithLegacyEncryption()
|
||||
->where('email', '!=', config('app.demo.email'))
|
||||
->where(function (Builder $query) use ($cutoff): void {
|
||||
$query->whereNull('last_active_at')
|
||||
->orWhere('last_active_at', '<', $cutoff);
|
||||
})
|
||||
->get();
|
||||
$users = $this->excludeBilledUsers(
|
||||
$this->usersWithLegacyEncryption()
|
||||
->where('email', '!=', config('app.demo.email'))
|
||||
->where(function (Builder $query) use ($cutoff): void {
|
||||
$query->whereNull('last_active_at')
|
||||
->orWhere('last_active_at', '<', $cutoff);
|
||||
})
|
||||
->get()
|
||||
);
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No accounts to delete.');
|
||||
|
|
|
|||
|
|
@ -39,16 +39,22 @@ class NotifyEncryptedDataRemovalCommand extends Command
|
|||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$users = $this->usersWithLegacyEncryption()->get();
|
||||
$encryptedUsers = $this->usersWithLegacyEncryption()->get();
|
||||
|
||||
// Always report the total scope of legacy encrypted data (soft-deleted users are
|
||||
// excluded by the model's default scope). This is the signal for deciding whether
|
||||
// the browser-side encryption code can finally be removed, independent of who we
|
||||
// actually email below.
|
||||
$this->info("{$encryptedUsers->count()} non-deleted user(s) still have encrypted data.");
|
||||
|
||||
$users = $this->excludeBilledUsers($encryptedUsers);
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No users with encrypted data found.');
|
||||
$this->info('No users to warn.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$users->count()} user(s) with encrypted data.");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->renderTable($users);
|
||||
$this->info('[dry-run] No emails sent.');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Console\Commands\Concerns\RendersReportToConsole;
|
|||
use App\Models\User;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Stripe\SubscriptionStatsCollector;
|
||||
use App\Support\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
|
|
@ -115,17 +116,12 @@ class SendDailyStatsReportCommand extends Command
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* MRR figures are held in major units (e.g. euros), so convert to cents for
|
||||
* the shared formatter.
|
||||
*/
|
||||
private function money(float $amount, string $currency): string
|
||||
{
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'usd' => '$',
|
||||
'jpy' => '¥',
|
||||
'brl' => 'R$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amount, 2);
|
||||
return Money::format((int) round($amount * 100), $currency);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Features\SubscriptionExperiment;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Stats\BinomialProportion;
|
||||
use App\Services\Stats\ExperimentFunnelCollector;
|
||||
use App\Services\Stats\ProportionSignificance;
|
||||
use App\Support\Money;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendExperimentFunnelReportCommand extends Command
|
||||
{
|
||||
protected $signature = 'stats:experiment-funnel {--no-discord : Print the report to the console only, without posting to Discord}';
|
||||
protected $signature = 'stats:experiment-funnel
|
||||
{--no-discord : Print the report to the console only, without posting to Discord}
|
||||
{--cost-per-connection=0.4 : Estimated cost (in the Cashier currency) per bank connection, used for the Cost/Burn/CM columns}';
|
||||
|
||||
protected $description = 'Post the trial/pricing experiment funnel (per variant) to Discord';
|
||||
|
||||
|
|
@ -20,14 +25,17 @@ class SendExperimentFunnelReportCommand extends Command
|
|||
SubscriptionExperiment::PAY_NOW => 'pay_now',
|
||||
];
|
||||
|
||||
public function __construct(private ExperimentFunnelCollector $collector)
|
||||
{
|
||||
public function __construct(
|
||||
private ExperimentFunnelCollector $collector,
|
||||
private ProportionSignificance $significance,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$report = $this->collector->collect();
|
||||
$costPerConnectionCents = (int) round(((float) $this->option('cost-per-connection')) * 100);
|
||||
$report = $this->collector->collect($costPerConnectionCents);
|
||||
|
||||
if ($report['startedAt'] === null) {
|
||||
$this->warn('Experiment not started — set SUBSCRIPTION_EXPERIMENT_STARTED_AT to begin.');
|
||||
|
|
@ -39,6 +47,10 @@ class SendExperimentFunnelReportCommand extends Command
|
|||
$this->line($line);
|
||||
}
|
||||
|
||||
foreach ($this->significanceLines($report) as $line) {
|
||||
$this->line($line);
|
||||
}
|
||||
|
||||
if ($this->option('no-discord')) {
|
||||
$this->info('Skipped Discord (--no-discord).');
|
||||
|
||||
|
|
@ -56,49 +68,112 @@ class SendExperimentFunnelReportCommand extends Command
|
|||
}
|
||||
|
||||
/**
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, variants: array<string, array<string, mixed>>} $report
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
|
||||
* @return list<string>
|
||||
*/
|
||||
private function tableLines(array $report): array
|
||||
{
|
||||
$revenue = $report['revenueAvailable'];
|
||||
$lines = [sprintf('%-8s %5s %4s %5s %5s %5s %5s %8s %8s', 'Variant', 'Assg', 'Sub', 'Actv', 'Cncl', 'Rfnd', 'Net%', 'MRR', 'ARPU')];
|
||||
$currency = $report['currency'];
|
||||
$lines = [sprintf(
|
||||
'%-8s %5s %5s %5s %5s %5s %6s %7s %7s %7s %7s %7s',
|
||||
'Variant', 'Assg', 'Actd', 'Card', 'MatU', 'Conv', 'Conv%', 'ARPU', 'MRR', 'Cost', 'Burn', 'CM',
|
||||
)];
|
||||
|
||||
foreach (self::LABELS as $key => $label) {
|
||||
$row = $report['variants'][$key];
|
||||
$mature = $row['assignedMature'] > 0;
|
||||
$showMoney = $revenue && $mature;
|
||||
|
||||
$lines[] = sprintf(
|
||||
'%-8s %5d %4d %5d %5d %5d %5s %8s %8s',
|
||||
'%-8s %5d %5d %5d %5d %5d %6s %7s %7s %7s %7s %7s',
|
||||
$label,
|
||||
$row['assigned'],
|
||||
$row['activated'],
|
||||
$row['subscribed'],
|
||||
$row['active'],
|
||||
$row['canceled'],
|
||||
$row['refunded'],
|
||||
$mature ? ((int) round($row['netActiveRate'] * 100)).'%' : 'pend',
|
||||
$revenue ? $this->money($row['mrrCents'], $report['currency']) : '—',
|
||||
$revenue && $mature ? $this->money((int) $row['arpuCents'], $report['currency']) : '—',
|
||||
$row['assignedMature'],
|
||||
$row['convertedMature'],
|
||||
$mature ? ((int) round($row['conversionRate'] * 100)).'%' : 'pend',
|
||||
$showMoney && $row['arpuCents'] !== null ? Money::format($row['arpuCents'], $currency) : '—',
|
||||
$showMoney ? Money::format($row['mrrCents'], $currency) : '—',
|
||||
$mature ? Money::format($row['costCents'], $currency) : '—',
|
||||
$mature ? Money::format($row['wastedCostCents'], $currency) : '—',
|
||||
$showMoney ? Money::format($row['contributionMarginCents'], $currency) : '—',
|
||||
);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function money(int $cents, string $currency): string
|
||||
/**
|
||||
* Per-variant conversion-rate uncertainty (95% Wilson interval) plus the
|
||||
* leader-vs-runner-up verdict from {@see ProportionSignificance} — a Fisher
|
||||
* exact test and a Newcombe difference interval, Bonferroni-corrected — so
|
||||
* "check significance before calling a winner" has the numbers behind it.
|
||||
*
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
|
||||
* @return list<string>
|
||||
*/
|
||||
private function significanceLines(array $report): array
|
||||
{
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'usd' => '$',
|
||||
default => $currency.' ',
|
||||
};
|
||||
$lines = ['', 'Significance (95% Wilson CI on Conv%, n = MatU):'];
|
||||
$arms = [];
|
||||
|
||||
return $symbol.number_format($cents / 100, 2);
|
||||
foreach (self::LABELS as $key => $label) {
|
||||
$row = $report['variants'][$key];
|
||||
$n = (int) $row['assignedMature'];
|
||||
$k = (int) $row['convertedMature'];
|
||||
|
||||
if ($n <= 0) {
|
||||
$lines[] = sprintf(' %-8s pend (n=0)', $label);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$low, $high] = $this->significance->wilsonInterval($k, $n);
|
||||
$lines[] = sprintf(' %-8s %6s [%6s – %6s] (n=%d)', $label, $this->percent($k / $n), $this->percent($low), $this->percent($high), $n);
|
||||
$arms[] = new BinomialProportion($label, $k, $n);
|
||||
}
|
||||
|
||||
if (count($arms) < 2) {
|
||||
$lines[] = 'Not enough matured variants to compare yet.';
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
usort($arms, fn (BinomialProportion $a, BinomialProportion $b): int => $b->rate() <=> $a->rate());
|
||||
[$leader, $runnerUp] = [$arms[0], $arms[1]];
|
||||
$result = $this->significance->compare($leader, $runnerUp);
|
||||
|
||||
$lines[] = sprintf(
|
||||
'Leader %s vs %s: Δ %+.1f pts (95%% CI %+.1f … %+.1f pts, Newcombe).',
|
||||
$leader->label, $runnerUp->label,
|
||||
($leader->rate() - $runnerUp->rate()) * 100, $result['diffLow'] * 100, $result['diffHigh'] * 100,
|
||||
);
|
||||
$lines[] = sprintf(
|
||||
'Fisher exact p=%.3f %s α=%.3f (Bonferroni×3) -> %s.%s',
|
||||
$result['fisherP'], $result['significant'] ? '<' : '≥', $result['alpha'],
|
||||
$result['significant'] ? 'significant' : 'not significant',
|
||||
$result['significant'] ? '' : ' Keep running.',
|
||||
);
|
||||
|
||||
if ($result['minExpectedCount'] < 5.0) {
|
||||
$lines[] = sprintf(
|
||||
'(Small sample: min expected conversions %.1f < 5, so the normal-approx z=%.2f overstates — exact test used.)',
|
||||
$result['minExpectedCount'], $result['z'],
|
||||
);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function percent(float $rate): string
|
||||
{
|
||||
return number_format($rate * 100, 1).'%';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, variants: array<string, array<string, mixed>>} $report
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildEmbed(array $report): array
|
||||
|
|
@ -113,14 +188,22 @@ class SendExperimentFunnelReportCommand extends Command
|
|||
'value' => $report['startedAt']->format('D, d M Y').' · new signups split evenly into the three variants.',
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => '📊 Significance',
|
||||
'value' => "```\n".implode("\n", $this->significanceLines($report))."\n```",
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'Legend',
|
||||
'value' => 'Assg = assigned · Sub = started a plan · Actv/Cncl = current status · Rfnd = self-service refunds (pay_now) · Net% = live, non-refunded subs ÷ assigned (mature users only) · MRR = monthly run-rate of those subs (yearly ÷ 12) · ARPU = MRR ÷ assigned · `pend`/`—` = no mature data yet.',
|
||||
'value' => sprintf(
|
||||
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Conv = matured users who ever converted (were charged, net of refund) — time-invariant, so it does not shrink as an older cohort has longer to churn · Conv%% = Conv ÷ MatU (always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of *currently* paying subs (yearly ÷ 12); Conv above MRR is churn · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users who never earned net revenue (connected a bank but never paid, or paid then refunded) · CM = MRR − Cost · `pend`/`—` = no matured data yet.',
|
||||
Money::format($report['costPerConnectionCents'], $report['currency']),
|
||||
),
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => '⚠️ How to read it',
|
||||
'value' => 'Each variant is gated by its own decision window (control 15d, reduced 7d, pay_now 3d), so pay_now matures first — compare only once all three have mature volume, and check significance before calling a winner. **ARPU is the revenue metric to compare.** MRR is run-rate, so it does not credit pay_now\'s yearly upfront cash; true LTV also needs a churn rate the experiment is too young to have.',
|
||||
'value' => 'Each variant matures on its own decision window (control 15d, reduced 7d, pay_now 3d, +3d settle), so at any moment MatU differs a lot between variants (pay_now matures first). **Compare variants on Conv% and ARPU — normalized per matured user — not on the absolute MRR/Cost/Burn/CM totals, which scale with MatU and so mechanically favour whichever variant has matured more.** Assg/Actd/Card are lifetime counts; everything from MatU rightward covers the matured cohort only, so the raw Actd→Card→Conv funnel mixes cohorts (immature carded users can\'t have matured yet) — read it for volume. Conv counts anyone ever charged (net of refund), so it is not depressed for older cohorts the way a live-active snapshot would be. Per-user CM is sub-cent at current volume, so treat CM as directional context, not the decision. Check significance (sample size = MatU) before calling a winner. Cost is a flat per-connection estimate across all providers, not per-provider billing.',
|
||||
'inline' => false,
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Stripe\SubscriptionStatsCollector;
|
||||
use App\Support\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
|
||||
|
|
@ -77,17 +78,12 @@ class StripeSubscriptionStatsCommand extends Command
|
|||
$this->newLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* MRR figures are held in major units (e.g. euros), so convert to cents for
|
||||
* the shared formatter.
|
||||
*/
|
||||
private function format(float $amount, string $currency): string
|
||||
{
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'usd' => '$',
|
||||
'jpy' => '¥',
|
||||
'brl' => 'R$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amount, 2);
|
||||
return Money::format((int) round($amount * 100), $currency);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
|
|
@ -50,6 +51,7 @@ class SyncStripePricesCommand extends Command
|
|||
$amountInCents = (int) round($plan['price'] * 100);
|
||||
$billingPeriod = $plan['billing_period'] ?? null;
|
||||
$productId = config('subscriptions.products.pro');
|
||||
$formattedAmount = Money::format($amountInCents, $currency);
|
||||
|
||||
$this->line(" <options=bold>{$plan['name']}</>");
|
||||
|
||||
|
|
@ -68,7 +70,7 @@ class SyncStripePricesCommand extends Command
|
|||
|
||||
if (! $dryRun) {
|
||||
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: true);
|
||||
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
|
||||
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$formattedAmount}/{$billingPeriod})");
|
||||
} else {
|
||||
$this->line(" <fg=cyan>[dry-run]</> Would create new price and transfer lookup key '{$lookupKey}'");
|
||||
}
|
||||
|
|
@ -79,9 +81,9 @@ class SyncStripePricesCommand extends Command
|
|||
|
||||
if (! $dryRun) {
|
||||
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: false);
|
||||
$this->line(" <fg=green>✓</> Created {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
|
||||
$this->line(" <fg=green>✓</> Created {$price->id} ({$formattedAmount}/{$billingPeriod})");
|
||||
} else {
|
||||
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$this->formatAmount($amountInCents, $currency)}/{$billingPeriod}");
|
||||
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$formattedAmount}/{$billingPeriod}");
|
||||
}
|
||||
|
||||
$created++;
|
||||
|
|
@ -148,16 +150,4 @@ class SyncStripePricesCommand extends Command
|
|||
|
||||
return $currencyMatches && $amountMatches && $intervalMatches;
|
||||
}
|
||||
|
||||
private function formatAmount(int $amountInCents, string $currency): string
|
||||
{
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'jpy' => '¥',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amountInCents / 100, 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,17 @@ enum AccountType: string
|
|||
|
||||
public function reducesNetWorth(): bool
|
||||
{
|
||||
return in_array($this, [self::CreditCard, self::Loan], true);
|
||||
return $this === self::Loan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account type is part of the net worth total at all. Credit
|
||||
* cards are spending accounts, not wealth, so they are excluded entirely
|
||||
* (neither added nor subtracted) while still being tracked on their own.
|
||||
*/
|
||||
public function countsInNetWorth(): bool
|
||||
{
|
||||
return $this !== self::CreditCard;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ImportConfigType: string
|
||||
{
|
||||
case Transaction = 'transaction';
|
||||
case Balance = 'balance';
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ enum PlanFeature: string
|
|||
{
|
||||
case ConnectedAccounts = 'connected_accounts';
|
||||
case AiSuggestions = 'ai_suggestions';
|
||||
case McpAccess = 'mcp_access';
|
||||
|
||||
/**
|
||||
* Whether access to this feature is gated behind a paid (Pro) plan.
|
||||
|
|
@ -13,7 +14,7 @@ enum PlanFeature: string
|
|||
public function requiresProPlan(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ConnectedAccounts, self::AiSuggestions => true,
|
||||
self::ConnectedAccounts, self::AiSuggestions, self::McpAccess => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* The upsell entry point a subscription checkout was started from, used to
|
||||
* attribute revenue to each upgrade prompt. The value is carried into Stripe as
|
||||
* subscription metadata and persisted onto the local subscription so revenue
|
||||
* can be measured per upsell point.
|
||||
*
|
||||
* Mirrored on the frontend by the UpsellSource union in
|
||||
* resources/js/components/subscription/upgrade-dialog.tsx — keep both in sync
|
||||
* when adding a point (an unknown value is silently dropped by tryFrom()).
|
||||
*/
|
||||
enum UpsellSource: string
|
||||
{
|
||||
case AiCategorization = 'ai_categorization';
|
||||
case Connections = 'connections';
|
||||
case Accounts = 'accounts';
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions\Banking;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Debug\ShouldntReport;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The banking provider rejected the requested transactions date range as wider
|
||||
* than the bank is willing to serve (EnableBanking HTTP 422 "Wrong transactions
|
||||
* period requested"). Recoverable by retrying with a narrower window, so it is
|
||||
* not reported: the sync layer clamps and retries, and only skips the account
|
||||
* if even the narrowest window is refused.
|
||||
*/
|
||||
class WrongTransactionsPeriodException extends Exception implements ShouldntReport
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Gates the MCP access settings screen while the feature is being rolled out.
|
||||
* Toggle per user / everyone with `php artisan feature:enable App\\Features\\Mcp <target>`.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class Mcp
|
||||
{
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
*/
|
||||
public function resolve(?User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\ImportConfigType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\UpdateAccountImportConfigRequest;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AccountImportConfigController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function show(Request $request, Account $account): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $account);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => ['required', Rule::enum(ImportConfigType::class)],
|
||||
]);
|
||||
|
||||
$config = $account->importConfigs()
|
||||
->where('type', $validated['type'])
|
||||
->first();
|
||||
|
||||
return response()->json(['data' => $config?->config]);
|
||||
}
|
||||
|
||||
public function update(UpdateAccountImportConfigRequest $request, Account $account): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $account);
|
||||
|
||||
$config = $account->importConfigs()->updateOrCreate(
|
||||
['type' => $request->validated('type')],
|
||||
['config' => $request->validated('config')],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $config->config]);
|
||||
}
|
||||
}
|
||||
|
|
@ -482,6 +482,10 @@ class DashboardAnalyticsController extends Controller
|
|||
$total = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if (! $account->type->countsInNetWorth()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance = $lookup->getBalanceAt($account->id, $date);
|
||||
|
||||
$convertedBalance = $this->exchangeRateService->convert(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Features\Mcp;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\StoreMcpTokenRequest;
|
||||
use Closure;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class McpTokenController extends Controller implements HasMiddleware
|
||||
{
|
||||
/**
|
||||
* Hide the whole MCP settings surface behind the rollout feature flag.
|
||||
*
|
||||
* @return array<int, Closure>
|
||||
*/
|
||||
public static function middleware(): array
|
||||
{
|
||||
return [
|
||||
function (Request $request, Closure $next): mixed {
|
||||
abort_unless(Feature::active(Mcp::class), 404);
|
||||
|
||||
return $next($request);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the MCP access page: existing tokens, connection details and the
|
||||
* one-time plaintext secret when a token was just created.
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/mcp', [
|
||||
'tokens' => $this->tokensFor($request),
|
||||
'serverUrl' => url('/mcp'),
|
||||
'oauthUrl' => url('/mcp/oauth'),
|
||||
'subscribeUrl' => route('subscribe'),
|
||||
'newToken' => $request->session()->get('mcp_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new MCP token. The plaintext secret is flashed once; only its
|
||||
* hash is stored, so it can never be shown again. A "read" token can only
|
||||
* analyse data; a "read_write" token additionally carries `mcp:write`,
|
||||
* unlocking the write tools.
|
||||
*/
|
||||
public function store(StoreMcpTokenRequest $request): RedirectResponse
|
||||
{
|
||||
$abilities = $request->validated('scope') === 'read_write'
|
||||
? ['mcp:read', 'mcp:write']
|
||||
: ['mcp:read'];
|
||||
|
||||
$token = $request->user()->createToken($request->validated('name'), $abilities);
|
||||
|
||||
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token the user owns.
|
||||
*/
|
||||
public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse
|
||||
{
|
||||
$this->authorizeOwnership($request, $token);
|
||||
|
||||
$token->delete();
|
||||
|
||||
return to_route('mcp.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a token: revoke it and issue a fresh secret keeping the same name
|
||||
* and scope, so a leaked token can be replaced without reconfiguring intent.
|
||||
*/
|
||||
public function rotate(Request $request, PersonalAccessToken $token): RedirectResponse
|
||||
{
|
||||
$this->authorizeOwnership($request, $token);
|
||||
|
||||
$fresh = $request->user()->createToken($token->name, $token->abilities);
|
||||
$token->delete();
|
||||
|
||||
return to_route('mcp.index')->with('mcp_token', $fresh->plainTextToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the token belongs to the requesting user before mutating it.
|
||||
*/
|
||||
private function authorizeOwnership(Request $request, PersonalAccessToken $token): void
|
||||
{
|
||||
abort_unless(
|
||||
$token->tokenable_id === $request->user()->getKey()
|
||||
&& $token->tokenable_type === $request->user()->getMorphClass(),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, name: string, scope: string, created_at: ?string, last_used_at: ?string}>
|
||||
*/
|
||||
private function tokensFor(Request $request): array
|
||||
{
|
||||
return $request->user()->tokens()
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn (PersonalAccessToken $token): array => [
|
||||
'id' => $token->id,
|
||||
'name' => $token->name,
|
||||
'scope' => in_array('mcp:write', $token->abilities ?? [], true) ? 'read_write' : 'read',
|
||||
'created_at' => $token->created_at?->toIso8601String(),
|
||||
'last_used_at' => $token->last_used_at?->toIso8601String(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Subscription\RefundSelfServe;
|
||||
use App\Enums\UpsellSource;
|
||||
use App\Features\SubscriptionExperiment;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\User;
|
||||
use App\Models\UserLead;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
|
|
@ -52,32 +52,14 @@ class SubscriptionController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int, automationRulesCount: int, balancesByCurrency: array<string, int>}
|
||||
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int}
|
||||
*/
|
||||
private function getUserStats(User $user): array
|
||||
{
|
||||
$accounts = $user->accounts()->get();
|
||||
|
||||
$balancesByCurrency = [];
|
||||
foreach ($accounts as $account) {
|
||||
$latestBalance = AccountBalance::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderBy('balance_date', 'desc')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
$currency = $account->currency_code;
|
||||
if (! isset($balancesByCurrency[$currency])) {
|
||||
$balancesByCurrency[$currency] = 0;
|
||||
}
|
||||
$balancesByCurrency[$currency] += $latestBalance;
|
||||
}
|
||||
|
||||
return [
|
||||
'accountsCount' => $accounts->count(),
|
||||
'accountsCount' => $user->accounts()->count(),
|
||||
'transactionsCount' => $user->transactions()->count(),
|
||||
'categoriesCount' => $user->categories()->count(),
|
||||
'automationRulesCount' => $user->automationRules()->count(),
|
||||
'balancesByCurrency' => $balancesByCurrency,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +88,14 @@ class SubscriptionController extends Controller
|
|||
$subscriptionBuilder->trialDays($trialDays);
|
||||
}
|
||||
|
||||
// Attribute revenue to the upsell point the checkout started from. The
|
||||
// value rides along as Stripe subscription metadata and is persisted
|
||||
// locally when the subscription webhook lands (see
|
||||
// PersistUpsellSourceFromStripe).
|
||||
if ($source = UpsellSource::tryFrom((string) $request->query('source', ''))) {
|
||||
$subscriptionBuilder->withMetadata(['upsell_source' => $source->value]);
|
||||
}
|
||||
|
||||
return $subscriptionBuilder->checkout([
|
||||
'success_url' => route('subscribe.success'),
|
||||
'cancel_url' => route('subscribe.cancel'),
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ class TransactionController extends Controller
|
|||
], 201);
|
||||
}
|
||||
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $transaction);
|
||||
|
||||
|
|
@ -238,6 +238,10 @@ class TransactionController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
// Snapshot the pre-edit account/date/amount before filling, so a manual
|
||||
// account balance can be moved off the old values if the edit changes them.
|
||||
$originalSnapshot = clone $transaction;
|
||||
|
||||
// Update attributes directly without firing events yet
|
||||
if (! empty($data)) {
|
||||
$transaction->fill($data);
|
||||
|
|
@ -260,6 +264,18 @@ class TransactionController extends Controller
|
|||
$transaction->save();
|
||||
}
|
||||
|
||||
// Move the manual account balance to match an edited amount/date/account:
|
||||
// strip the pre-edit contribution (exactly as a deletion would) and apply
|
||||
// the new one (exactly as a creation would), both cascading forward.
|
||||
// ponytail: like create/delete, this trusts the opt-in flag and keeps no
|
||||
// record of whether creation adjusted the balance, so mixing the flag
|
||||
// across create and edit can drift; a transaction-derived balance would
|
||||
// remove that trust. Connected accounts are skipped inside the adjuster.
|
||||
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
|
||||
$balanceAdjuster->reverseDeletedTransaction($originalSnapshot);
|
||||
$balanceAdjuster->applyCreatedTransaction($transaction->load('account'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh()->load('labels'),
|
||||
'learned_rule' => $learnedRule === null ? null : [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Middleware;
|
|||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Features\CalculateBalancesOnImport;
|
||||
use App\Features\Mcp;
|
||||
use App\Jobs\PurgeResidualEncryptionArtifactsJob;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Services\CurrencyOptions;
|
||||
|
|
@ -179,16 +180,19 @@ class HandleInertiaRequests extends Middleware
|
|||
return [
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => false,
|
||||
'mcp' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$features = Feature::for($user)->values([
|
||||
CalculateBalancesOnImport::class,
|
||||
Mcp::class,
|
||||
]);
|
||||
|
||||
return [
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
|
||||
'mcp' => $features[Mcp::class] !== false,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use App\Enums\ImportConfigType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateAccountImportConfigRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Authorization is handled by the controller via the AccountPolicy.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', Rule::enum(ImportConfigType::class)],
|
||||
'config' => ['required', 'array'],
|
||||
'config.columnMapping' => ['required', 'array'],
|
||||
'config.dateFormat' => ['required', 'string', 'max:20'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -24,7 +24,10 @@ trait ValidatesAccountDetailRules
|
|||
],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'purchase_price' => ['nullable', 'integer', 'min:0'],
|
||||
'purchase_date' => ['nullable', 'date', 'before_or_equal:today'],
|
||||
// Floor the date: a mistyped/ancient year would make the historical
|
||||
// balance generator build a multi-century monthly series and OOM the
|
||||
// queue worker (PHP-LARAVEL-49). 1900 rejects typos, not real assets.
|
||||
'purchase_date' => ['nullable', 'date', 'after_or_equal:1900-01-01', 'before_or_equal:today'],
|
||||
'area_value' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
|
||||
'area_unit' => ['nullable', 'string', Rule::in(['sqm', 'sqft', 'acres', 'hectares'])],
|
||||
'linked_loan_account_id' => [
|
||||
|
|
@ -52,7 +55,9 @@ trait ValidatesAccountDetailRules
|
|||
return [
|
||||
'annual_interest_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'loan_term_months' => ['nullable', 'integer', 'min:1', 'max:600'],
|
||||
'loan_start_date' => ['nullable', 'date'],
|
||||
// Floor the date for the same reason as purchase_date above: an
|
||||
// ancient loan start date OOMs the balance generator (PHP-LARAVEL-49).
|
||||
'loan_start_date' => ['nullable', 'date', 'after_or_equal:1900-01-01'],
|
||||
'original_amount' => ['nullable', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreMcpTokenRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'scope' => ['required', 'in:read,read_write'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Http\Requests\Concerns\ValidatesUserOwnedResources;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTransactionRequest extends FormRequest
|
||||
|
|
@ -16,7 +18,7 @@ class UpdateTransactionRequest extends FormRequest
|
|||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
$rules = [
|
||||
'category_id' => ['nullable', $this->userOwned('categories')],
|
||||
'description' => ['sometimes', 'string'],
|
||||
'description_iv' => ['nullable', 'string', 'size:16'],
|
||||
|
|
@ -27,6 +29,20 @@ class UpdateTransactionRequest extends FormRequest
|
|||
'label_ids' => ['nullable', 'array'],
|
||||
'label_ids.*' => ['required', 'string', 'uuid', $this->userOwned('labels')],
|
||||
];
|
||||
|
||||
// Manually created transactions can edit every field after creation.
|
||||
// Imported ones keep amount, date, account and currency locked to the
|
||||
// source data, so those keys are only validated (and thus persisted)
|
||||
// for manual transactions.
|
||||
$transaction = $this->route('transaction');
|
||||
if ($transaction instanceof Transaction && $transaction->source === TransactionSource::ManuallyCreated) {
|
||||
$rules['account_id'] = ['sometimes', $this->userOwned('accounts')];
|
||||
$rules['transaction_date'] = ['sometimes', 'date'];
|
||||
$rules['amount'] = ['sometimes', 'integer'];
|
||||
$rules['currency_code'] = ['sometimes', 'string', 'size:3'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Enums\UpsellSource;
|
||||
use Laravel\Cashier\Events\WebhookHandled;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
/**
|
||||
* Copies the upsell attribution set at checkout (Stripe subscription metadata)
|
||||
* onto the local subscription row so revenue can be measured per upsell point.
|
||||
*
|
||||
* Listens to WebhookHandled (fired after Cashier has already upserted the local
|
||||
* subscription) and only fills the column while it's empty, so a later
|
||||
* subscription.updated event never overwrites the original attribution.
|
||||
*/
|
||||
class PersistUpsellSourceFromStripe
|
||||
{
|
||||
public function handle(WebhookHandled $event): void
|
||||
{
|
||||
$type = $event->payload['type'] ?? null;
|
||||
|
||||
if (! is_string($type) || ! str_starts_with($type, 'customer.subscription.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$object = $event->payload['data']['object'] ?? [];
|
||||
$stripeId = $object['id'] ?? null;
|
||||
$source = UpsellSource::tryFrom((string) ($object['metadata']['upsell_source'] ?? ''));
|
||||
|
||||
if (! is_string($stripeId) || $stripeId === '' || $source === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Subscription::query()
|
||||
->where('stripe_id', $stripeId)
|
||||
->whereNull('upsell_source')
|
||||
->update(['upsell_source' => $source->value]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Listeners;
|
|||
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Stripe\StripeCustomerResolver;
|
||||
use App\Support\Money;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
|
@ -171,7 +172,7 @@ class PostStripeEventToDiscord implements ShouldQueue
|
|||
$email = $this->stringOrNull($object['customer_email'] ?? null);
|
||||
|
||||
$fields = [
|
||||
$this->field('Amount', $this->money((int) $meta['amount'], (string) ($object['currency'] ?? 'usd')), true),
|
||||
$this->field('Amount', Money::format((int) $meta['amount'], (string) ($object['currency'] ?? 'usd')), true),
|
||||
$this->field('Customer', $email ?? $this->customers->label($this->stringOrNull($object['customer'] ?? null)), true),
|
||||
];
|
||||
|
||||
|
|
@ -237,7 +238,7 @@ class PostStripeEventToDiscord implements ShouldQueue
|
|||
return null;
|
||||
}
|
||||
|
||||
$label = $this->money((int) $amount, (string) ($price['currency'] ?? 'usd'));
|
||||
$label = Money::format((int) $amount, (string) ($price['currency'] ?? 'usd'));
|
||||
$recurring = is_array($price['recurring'] ?? null) ? $price['recurring'] : $price;
|
||||
|
||||
if (! isset($recurring['interval'])) {
|
||||
|
|
@ -260,20 +261,6 @@ class PostStripeEventToDiscord implements ShouldQueue
|
|||
return is_string($reason) ? str_replace('_', ' ', $reason) : null;
|
||||
}
|
||||
|
||||
private function money(int $amount, string $currency): string
|
||||
{
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'usd' => '$',
|
||||
'jpy' => '¥',
|
||||
'brl' => 'R$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amount / 100, 2);
|
||||
}
|
||||
|
||||
private function timestamp(mixed $value): ?string
|
||||
{
|
||||
if (! is_int($value) && ! (is_string($value) && ctype_digit($value))) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Servers;
|
||||
|
||||
use App\Mcp\Tools\CategorizeTransaction;
|
||||
use App\Mcp\Tools\CreateAutomationRule;
|
||||
use App\Mcp\Tools\CreateBalance;
|
||||
use App\Mcp\Tools\CreateCategory;
|
||||
use App\Mcp\Tools\CreateLabel;
|
||||
use App\Mcp\Tools\CreateTransaction;
|
||||
use App\Mcp\Tools\DeleteAutomationRule;
|
||||
use App\Mcp\Tools\DeleteCategory;
|
||||
use App\Mcp\Tools\DeleteLabel;
|
||||
use App\Mcp\Tools\DeleteTransaction;
|
||||
use App\Mcp\Tools\GetCashflow;
|
||||
use App\Mcp\Tools\GetNetWorth;
|
||||
use App\Mcp\Tools\LabelTransaction;
|
||||
use App\Mcp\Tools\ListAccounts;
|
||||
use App\Mcp\Tools\ListCategories;
|
||||
use App\Mcp\Tools\ListLabels;
|
||||
use App\Mcp\Tools\ListSpaces;
|
||||
use App\Mcp\Tools\SearchTransactions;
|
||||
use App\Mcp\Tools\SpendingByCategory;
|
||||
use App\Mcp\Tools\UpdateAutomationRule;
|
||||
use App\Mcp\Tools\UpdateCategory;
|
||||
use App\Mcp\Tools\UpdateLabel;
|
||||
use App\Mcp\Tools\UpdateTransaction;
|
||||
use Laravel\Mcp\Server;
|
||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||
use Laravel\Mcp\Server\Attributes\Name;
|
||||
use Laravel\Mcp\Server\Attributes\Version;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Name('Whisper Money')]
|
||||
#[Version('1.0.0')]
|
||||
#[Instructions(<<<'MARKDOWN'
|
||||
Access to the authenticated user's Whisper Money finance data, for analysing
|
||||
spending, cashflow and net worth — and, with write access, for editing that
|
||||
data.
|
||||
|
||||
- All amounts are integers in minor units (cents). Divide by 100 for a display value.
|
||||
- Data is organised into "spaces" (the personal space and any shared spaces).
|
||||
Transaction, account, category and label tools accept an optional `space` id and
|
||||
default to the personal space; call `list_spaces` to discover ids. The cashflow,
|
||||
net-worth and spending tools cover the user's whole account.
|
||||
- To find recurring charges (subscriptions), use `search_transactions` and group
|
||||
the results by merchant and cadence yourself.
|
||||
|
||||
Write tools (create_transaction, update_transaction, delete_transaction,
|
||||
categorize_transaction, label_transaction, create_balance and full CRUD for
|
||||
categories, labels and automation rules) require a read & write token; a
|
||||
read-only token can analyse data but never change it. Bank-connected accounts
|
||||
and bank/imported transactions are protected: you can only create, edit or
|
||||
delete manual transactions and manual-account balances, but you can categorize
|
||||
and label any transaction.
|
||||
MARKDOWN)]
|
||||
class WhisperMoneyServer extends Server
|
||||
{
|
||||
/** @var array<int, class-string<Tool>> */
|
||||
protected array $tools = [
|
||||
// Read
|
||||
SearchTransactions::class,
|
||||
SpendingByCategory::class,
|
||||
GetCashflow::class,
|
||||
GetNetWorth::class,
|
||||
ListAccounts::class,
|
||||
ListCategories::class,
|
||||
ListLabels::class,
|
||||
ListSpaces::class,
|
||||
|
||||
// Write
|
||||
CreateTransaction::class,
|
||||
UpdateTransaction::class,
|
||||
DeleteTransaction::class,
|
||||
CategorizeTransaction::class,
|
||||
LabelTransaction::class,
|
||||
CreateBalance::class,
|
||||
CreateCategory::class,
|
||||
UpdateCategory::class,
|
||||
DeleteCategory::class,
|
||||
CreateLabel::class,
|
||||
UpdateLabel::class,
|
||||
DeleteLabel::class,
|
||||
CreateAutomationRule::class,
|
||||
UpdateAutomationRule::class,
|
||||
DeleteAutomationRule::class,
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Set (or clear) the category of any transaction, including bank/imported ones. Marks the category as manually assigned. Pass category_id: null to remove the category.')]
|
||||
class CategorizeTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the transaction to categorize.')->required(),
|
||||
'category_id' => $schema->string()->description('Category id to assign, or null to remove the category.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
if (! $request->has('category_id')) {
|
||||
return Response::error('Provide category_id to set the category (or null to remove it).');
|
||||
}
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
$categoryId = $request->filled('category_id')
|
||||
? $this->categoryInSpace($request, $space)->id
|
||||
: null;
|
||||
|
||||
$transaction->category_id = $categoryId;
|
||||
$transaction->category_source = $categoryId === null ? null : CategorySource::Manual;
|
||||
$transaction->ai_confidence = null;
|
||||
$transaction->categorized_by_rule_id = null;
|
||||
$transaction->save();
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Concerns;
|
||||
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
|
||||
trait DecodesRulesJson
|
||||
{
|
||||
/**
|
||||
* Decode and validate a JsonLogic condition argument, accepting either a
|
||||
* JSON object or a JSON-encoded string.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function rulesJson(Request $request, string $key = 'rules_json'): array
|
||||
{
|
||||
$rulesJson = $request->get($key);
|
||||
|
||||
if (is_string($rulesJson)) {
|
||||
$rulesJson = json_decode($rulesJson, true);
|
||||
}
|
||||
|
||||
if (! is_array($rulesJson) || $rulesJson === []) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "{$key} must be a non-empty JsonLogic object.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $rulesJson;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools\Concerns;
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Space;
|
||||
use App\Services\CategoryTree;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
|
||||
/**
|
||||
* Category create/update helpers shared by the category write tools. Mirrors
|
||||
* the web form's parent resolution (ownership, depth, cycle) and its
|
||||
* type-driven cashflow derivation, without depending on `auth()` (which is not
|
||||
* the active guard for MCP requests).
|
||||
*/
|
||||
trait ResolvesCategoryWrites
|
||||
{
|
||||
/**
|
||||
* Resolve the requested parent category within the space, enforcing the
|
||||
* same rules as the web form. Returns null when creating/keeping a root.
|
||||
* $moving is the category being edited (null on create) so its own subtree
|
||||
* depth and cycle constraints can be checked.
|
||||
*/
|
||||
protected function resolveParentCategory(Request $request, Space $space, ?Category $moving): ?Category
|
||||
{
|
||||
if (! $request->filled('parent_id')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parentId = $request->string('parent_id')->toString();
|
||||
|
||||
$parent = Category::query()->forSpace($space)->whereKey($parentId)->first();
|
||||
|
||||
if ($parent === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => "No category with id {$parentId} in space {$space->id}. Call list_categories to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
$tree = new CategoryTree;
|
||||
|
||||
if ($moving !== null && $tree->wouldCreateCycle($moving, $parent->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => 'A category cannot be nested under itself or one of its children.',
|
||||
]);
|
||||
}
|
||||
|
||||
$subtreeDepth = $moving !== null ? $tree->subtreeDepth($moving) : 1;
|
||||
|
||||
if ($tree->depth($parent) + $subtreeDepth > Category::MAX_DEPTH) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => 'Categories can only be nested up to '.Category::MAX_DEPTH.' levels deep.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the cashflow direction the web form would compute: a child inherits
|
||||
* its parent's direction; a root follows its type (savings/investment always
|
||||
* count as an outflow, transfers keep the requested direction, everything
|
||||
* else is hidden).
|
||||
*/
|
||||
protected function cashflowDirectionFor(CategoryType $type, ?Category $parent, ?string $requested): CategoryCashflowDirection
|
||||
{
|
||||
if ($parent !== null) {
|
||||
return $parent->cashflow_direction;
|
||||
}
|
||||
|
||||
return match ($type) {
|
||||
CategoryType::Savings, CategoryType::Investment => CategoryCashflowDirection::Outflow,
|
||||
CategoryType::Transfer => CategoryCashflowDirection::tryFrom((string) $requested) ?? CategoryCashflowDirection::Hidden,
|
||||
default => CategoryCashflowDirection::Hidden,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sibling category (same parent) with the given name already
|
||||
* exists in the space, optionally ignoring one category by id (for edits).
|
||||
*/
|
||||
protected function categoryNameTaken(Space $space, string $name, ?string $parentId, ?string $ignoreId = null): bool
|
||||
{
|
||||
return Category::query()
|
||||
->forSpace($space)
|
||||
->where('name', $name)
|
||||
->where('parent_id', $parentId)
|
||||
->when($ignoreId !== null, fn ($query) => $query->whereKeyNot($ignoreId))
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\RuleOrigin;
|
||||
use App\Mcp\Tools\Concerns\DecodesRulesJson;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description(<<<'TEXT'
|
||||
Create an automation rule that auto-applies a category and/or labels to matching transactions. `rules_json` is a JsonLogic object evaluated against these lowercase variables: description, notes, creditor_name, debtor_name, account_name, bank_name, category, transaction_date (YYYY-MM-DD) and amount. Note: amount here is in MAJOR units (e.g. 12.50), not cents. Example: {"and":[{">":[{"var":"amount"},100]},{"in":["grocery",{"var":"description"}]}]}. At least one action (action_category_id or action_label_ids) is required.
|
||||
TEXT)]
|
||||
class CreateAutomationRule extends WriteTool
|
||||
{
|
||||
use DecodesRulesJson;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'title' => $schema->string()->description('Human-readable rule name.')->required(),
|
||||
'priority' => $schema->integer()->min(0)->description('Lower numbers are evaluated first.')->required(),
|
||||
'rules_json' => $schema->object()->description('JsonLogic condition object.')->required(),
|
||||
'action_category_id' => $schema->string()->description('Category id to assign to matching transactions.'),
|
||||
'action_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach to matching transactions.'),
|
||||
'action_note' => $schema->string()->description('Note to append to matching transactions.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'priority' => ['required', 'integer', 'min:0'],
|
||||
'action_note' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$rulesJson = $this->rulesJson($request);
|
||||
$labels = $this->labelsInSpace($request, $space, 'action_label_ids');
|
||||
$categoryId = $request->filled('action_category_id')
|
||||
? $this->categoryInSpace($request, $space, 'action_category_id')->id
|
||||
: null;
|
||||
|
||||
if ($categoryId === null && $labels->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'action_category_id' => 'At least one action is required: pass action_category_id and/or action_label_ids.',
|
||||
]);
|
||||
}
|
||||
|
||||
$rule = new AutomationRule([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'title' => $request->string('title')->toString(),
|
||||
'priority' => $request->integer('priority'),
|
||||
'origin' => RuleOrigin::User->value,
|
||||
'rules_json' => $rulesJson,
|
||||
'action_category_id' => $categoryId,
|
||||
'action_note' => $request->filled('action_note') ? $request->string('action_note')->toString() : null,
|
||||
]);
|
||||
$rule->save();
|
||||
|
||||
if ($labels->isNotEmpty()) {
|
||||
$rule->labels()->sync($labels->pluck('id')->all());
|
||||
$rule->touch();
|
||||
}
|
||||
|
||||
return $this->json(['automation_rule' => $this->presentAutomationRule($rule)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Record an account balance snapshot on a non-connected (manual) account. Balance is an integer in minor units (cents). Replaces any existing snapshot for that date. Connected/bank accounts are read-only.')]
|
||||
class CreateBalance extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'account_id' => $schema->string()->description('Id of a non-connected (manual) account.')->required(),
|
||||
'balance' => $schema->integer()->description('Balance in minor units (cents).')->required(),
|
||||
'balance_date' => $schema->string()->description('Snapshot date, YYYY-MM-DD. Defaults to today.'),
|
||||
'invested_amount' => $schema->integer()->description('Optional invested amount in minor units (cents), for investment accounts.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'balance' => ['required', 'integer'],
|
||||
'balance_date' => ['sometimes', 'date'],
|
||||
'invested_amount' => ['sometimes', 'nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$account = $this->writableAccount($request, $space);
|
||||
|
||||
$balanceDate = $request->filled('balance_date')
|
||||
? $request->string('balance_date')->toString()
|
||||
: now()->toDateString();
|
||||
|
||||
$balance = AccountBalance::updateOrCreate(
|
||||
['account_id' => $account->id, 'balance_date' => $balanceDate],
|
||||
[
|
||||
'balance' => $request->integer('balance'),
|
||||
...$request->filled('invested_amount')
|
||||
? ['invested_amount' => $request->integer('invested_amount')]
|
||||
: [],
|
||||
],
|
||||
);
|
||||
|
||||
return $this->json([
|
||||
'balance' => [
|
||||
'id' => $balance->id,
|
||||
'account_id' => $balance->account_id,
|
||||
'balance_date' => $balance->balance_date->toDateString(),
|
||||
'balance' => $balance->balance,
|
||||
'invested_amount' => $balance->invested_amount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Create a category. A child (parent_id set) inherits its parent type and cashflow direction; a root follows its own type. Categories can be nested up to 3 levels deep.')]
|
||||
class CreateCategory extends WriteTool
|
||||
{
|
||||
use ResolvesCategoryWrites;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->description('Category name (unique among its siblings).')->required(),
|
||||
'icon' => $schema->string()->description('Icon name (e.g. ShoppingBag, Home, Car).')->required(),
|
||||
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('Category color.')->required(),
|
||||
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('Category type. Ignored for children (they inherit the parent type).')->required(),
|
||||
'parent_id' => $schema->string()->description('Optional parent category id to nest under.'),
|
||||
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'icon' => ['required', 'string'],
|
||||
'color' => ['required', Rule::enum(CategoryColor::class)],
|
||||
'type' => ['required', Rule::enum(CategoryType::class)],
|
||||
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$parent = $this->resolveParentCategory($request, $space, null);
|
||||
|
||||
$type = $parent !== null ? $parent->type : $request->enum('type', CategoryType::class);
|
||||
$cashflow = $this->cashflowDirectionFor($type, $parent, $request->string('cashflow_direction')->toString());
|
||||
|
||||
$name = $request->string('name')->toString();
|
||||
|
||||
if ($this->categoryNameTaken($space, $name, $parent?->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A category with that name already exists at this level.',
|
||||
]);
|
||||
}
|
||||
|
||||
$category = new Category([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'name' => $name,
|
||||
'icon' => $request->string('icon')->toString(),
|
||||
'color' => $request->string('color')->toString(),
|
||||
'type' => $type->value,
|
||||
'cashflow_direction' => $cashflow->value,
|
||||
'parent_id' => $parent?->id,
|
||||
]);
|
||||
$category->save();
|
||||
|
||||
return $this->json(['category' => $this->presentCategory($category)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\LabelColor;
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Create a label. Names are unique within the space.')]
|
||||
class CreateLabel extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'name' => $schema->string()->description('Label name (unique within the space).')->required(),
|
||||
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('Label color.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'color' => ['required', Rule::enum(LabelColor::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$name = $request->string('name')->toString();
|
||||
|
||||
if (Label::query()->forSpace($space)->where('name', $name)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A label with that name already exists.',
|
||||
]);
|
||||
}
|
||||
|
||||
$label = new Label([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'name' => $name,
|
||||
'color' => $request->string('color')->toString(),
|
||||
]);
|
||||
$label->save();
|
||||
|
||||
return $this->json(['label' => $this->presentLabel($label)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\ManualBalanceAdjuster;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Create a manual transaction on a non-connected (manual) account. Amount is a signed integer in minor units (cents): negative for an expense, positive for income. Connected/bank accounts are read-only.')]
|
||||
class CreateTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'account_id' => $schema->string()->description('Id of a non-connected (manual) account to add the transaction to.')->required(),
|
||||
'description' => $schema->string()->description('Human-readable description.')->required(),
|
||||
'amount' => $schema->integer()->description('Signed amount in minor units (cents). Negative = expense, positive = income.')->required(),
|
||||
'transaction_date' => $schema->string()->description('Transaction date, YYYY-MM-DD.')->required(),
|
||||
'currency_code' => $schema->string()->description('ISO 4217 currency code (3 letters). Defaults to the account currency.'),
|
||||
'category_id' => $schema->string()->description('Optional category id to assign.'),
|
||||
'creditor_name' => $schema->string()->description('Optional creditor (payee) name.'),
|
||||
'debtor_name' => $schema->string()->description('Optional debtor (payer) name.'),
|
||||
'notes' => $schema->string()->description('Optional free-text notes.'),
|
||||
'label_ids' => $schema->array()->items($schema->string())->description('Optional label ids to attach.'),
|
||||
'update_balance' => $schema->boolean()->description('When true, shift the manual account balance snapshots by this amount. Default false.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'account_id' => ['required', 'string'],
|
||||
'description' => ['required', 'string'],
|
||||
'amount' => ['required', 'integer'],
|
||||
'transaction_date' => ['required', 'date'],
|
||||
'currency_code' => ['sometimes', 'string', 'size:3'],
|
||||
'notes' => ['sometimes', 'nullable', 'string'],
|
||||
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$account = $this->writableAccount($request, $space);
|
||||
$labels = $this->labelsInSpace($request, $space, 'label_ids');
|
||||
|
||||
$categoryId = $request->filled('category_id')
|
||||
? $this->categoryInSpace($request, $space)->id
|
||||
: null;
|
||||
|
||||
$transaction = new Transaction([
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $categoryId,
|
||||
'category_source' => $categoryId === null ? null : CategorySource::Manual->value,
|
||||
'description' => $request->string('description')->toString(),
|
||||
'transaction_date' => $request->string('transaction_date')->toString(),
|
||||
'amount' => $request->integer('amount'),
|
||||
'currency_code' => $request->filled('currency_code')
|
||||
? mb_strtoupper($request->string('currency_code')->toString())
|
||||
: $account->currency_code,
|
||||
'notes' => $request->filled('notes') ? $request->string('notes')->toString() : null,
|
||||
'creditor_name' => $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null,
|
||||
'debtor_name' => $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null,
|
||||
'source' => TransactionSource::ManuallyCreated->value,
|
||||
]);
|
||||
$transaction->save();
|
||||
|
||||
if ($labels->isNotEmpty()) {
|
||||
$transaction->labels()->sync($labels->pluck('id')->all());
|
||||
}
|
||||
|
||||
if ($request->boolean('update_balance')) {
|
||||
app(ManualBalanceAdjuster::class)->applyCreatedTransaction($transaction->load('account'));
|
||||
}
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete an automation rule. Transactions it already categorized keep their category; the rule simply stops running on future transactions.')]
|
||||
class DeleteAutomationRule extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'automation_rule_id' => $schema->string()->description('Id of the automation rule to delete.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$id = $request->string('automation_rule_id')->toString();
|
||||
|
||||
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($rule === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
|
||||
]);
|
||||
}
|
||||
|
||||
$rule->delete();
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $rule->id]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategoryDeletionStrategy;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use App\Services\CategoryTree;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete a category. The strategy decides what happens to its child categories: "reparent" (default) lifts them to the deleted category\'s parent, "promote" turns them into roots, "cascade" deletes the whole subtree and uncategorizes affected transactions.')]
|
||||
class DeleteCategory extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'category_id' => $schema->string()->description('Id of the category to delete.')->required(),
|
||||
'strategy' => $schema->string()->enum(array_column(CategoryDeletionStrategy::cases(), 'value'))->description('How to handle child categories. Defaults to "reparent".'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'strategy' => ['sometimes', Rule::enum(CategoryDeletionStrategy::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$category = $this->categoryInSpace($request, $space);
|
||||
|
||||
$strategy = $request->enum('strategy', CategoryDeletionStrategy::class) ?? CategoryDeletionStrategy::Reparent;
|
||||
$tree = new CategoryTree;
|
||||
|
||||
match ($strategy) {
|
||||
CategoryDeletionStrategy::Cascade => $tree->deleteSubtree($category),
|
||||
CategoryDeletionStrategy::Promote => $this->detachChildrenAndDelete($category, null),
|
||||
CategoryDeletionStrategy::Reparent => $this->detachChildrenAndDelete($category, $category->parent_id),
|
||||
};
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $category->id, 'strategy' => $strategy->value]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the category's direct children to a new parent, then delete it.
|
||||
*/
|
||||
private function detachChildrenAndDelete(Category $category, ?string $newParentId): void
|
||||
{
|
||||
try {
|
||||
$category->children()->update(['parent_id' => $newParentId]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
throw ValidationException::withMessages([
|
||||
'strategy' => 'A category with the same name already exists at the destination level. Rename it first.',
|
||||
]);
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete a label. It is removed from every transaction it was attached to.')]
|
||||
class DeleteLabel extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'label_id' => $schema->string()->description('Id of the label to delete.')->required(),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$label = $this->labelInSpace($request, $space);
|
||||
|
||||
$label->delete();
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $label->id]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\User;
|
||||
use App\Services\ManualBalanceAdjuster;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Delete a manually-created transaction. Only manual transactions can be deleted; bank/imported ones cannot.')]
|
||||
class DeleteTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to delete.')->required(),
|
||||
'update_balance' => $schema->boolean()->description('When true, reverse this transaction from the manual account balance snapshots. Default false.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
if ($transaction->source !== TransactionSource::ManuallyCreated) {
|
||||
return Response::error('Only manually-created transactions can be deleted. This one came from a bank or import.');
|
||||
}
|
||||
|
||||
if ($request->boolean('update_balance')) {
|
||||
app(ManualBalanceAdjuster::class)->reverseDeletedTransaction($transaction);
|
||||
}
|
||||
|
||||
$transaction->delete();
|
||||
|
||||
return $this->json(['deleted' => true, 'id' => $transaction->id]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Http\Controllers\Api\CashflowAnalyticsController;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('The full cashflow picture for a date range as JSON, mirroring the app\'s cashflow screen: income/expense/savings/investment summary (current vs previous), the income-vs-expense category flow (sankey), and the monthly trend. Amounts are in minor units (cents). Covers the user\'s whole account.')]
|
||||
class GetCashflow extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
|
||||
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'from' => ['required', 'date'],
|
||||
'to' => ['required', 'date'],
|
||||
]);
|
||||
|
||||
$controller = app(CashflowAnalyticsController::class);
|
||||
$range = ['from' => $request->string('from')->toString(), 'to' => $request->string('to')->toString()];
|
||||
|
||||
return $this->json([
|
||||
'summary' => $this->callController($controller, 'summary', $user, $range),
|
||||
'sankey' => $this->callController($controller, 'sankey', $user, $range),
|
||||
'trend' => $this->callController($controller, 'trend', $user, $range),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Net worth for a date range as JSON: the current total vs the previous period, plus the per-account balance evolution over time. Set granularity to "monthly" (default) or "daily". Amounts are in minor units (cents). Covers the user\'s whole account.')]
|
||||
class GetNetWorth extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
|
||||
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
|
||||
'granularity' => $schema->string()->enum(['monthly', 'daily'])->description('Evolution granularity (default monthly).'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'from' => ['required', 'date'],
|
||||
'to' => ['required', 'date'],
|
||||
'granularity' => ['sometimes', 'in:monthly,daily'],
|
||||
]);
|
||||
|
||||
$controller = app(DashboardAnalyticsController::class);
|
||||
$range = ['from' => $request->string('from')->toString(), 'to' => $request->string('to')->toString()];
|
||||
$daily = $request->string('granularity')->toString() === 'daily';
|
||||
|
||||
return $this->json([
|
||||
'granularity' => $daily ? 'daily' : 'monthly',
|
||||
'current' => $this->callController($controller, 'netWorth', $user, $range),
|
||||
'evolution' => $this->callController(
|
||||
$controller,
|
||||
$daily ? 'netWorthDailyEvolution' : 'netWorthEvolution',
|
||||
$user,
|
||||
$range,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Add and/or remove labels on any transaction, including bank/imported ones. Pass add_label_ids and/or remove_label_ids.')]
|
||||
class LabelTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the transaction to label.')->required(),
|
||||
'add_label_ids' => $schema->array()->items($schema->string())->description('Label ids to attach.'),
|
||||
'remove_label_ids' => $schema->array()->items($schema->string())->description('Label ids to detach.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
$add = $this->labelsInSpace($request, $space, 'add_label_ids');
|
||||
$remove = $this->labelsInSpace($request, $space, 'remove_label_ids');
|
||||
|
||||
if ($add->isEmpty() && $remove->isEmpty()) {
|
||||
return Response::error('Provide add_label_ids and/or remove_label_ids (arrays of label ids).');
|
||||
}
|
||||
|
||||
if ($add->isNotEmpty()) {
|
||||
$transaction->labels()->syncWithoutDetaching($add->pluck('id')->all());
|
||||
}
|
||||
|
||||
if ($remove->isNotEmpty()) {
|
||||
$transaction->labels()->detach($remove->pluck('id')->all());
|
||||
}
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('List the user\'s accounts in a space, including whether each is connected to a bank/provider (connected accounts are read-only).')]
|
||||
class ListAccounts extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$accounts = Account::query()
|
||||
->forSpace($space)
|
||||
->with('bank:id,name')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Account $account): array => [
|
||||
'id' => $account->id,
|
||||
'name' => $account->name,
|
||||
'type' => $account->type->value,
|
||||
'currency' => $account->currency_code,
|
||||
'bank' => $account->bank?->name,
|
||||
'is_connected' => $account->isConnected(),
|
||||
]);
|
||||
|
||||
return $this->json([
|
||||
'space_id' => $space->id,
|
||||
'accounts' => $accounts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('List the user\'s categories in a space. Categories form a tree via parent_id; use the ids to filter search_transactions or spending_by_category.')]
|
||||
class ListCategories extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$categories = Category::query()
|
||||
->forSpace($space)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Category $category): array => [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
'type' => $category->type->value,
|
||||
'cashflow_direction' => $category->cashflow_direction->value,
|
||||
'parent_id' => $category->parent_id,
|
||||
]);
|
||||
|
||||
return $this->json([
|
||||
'space_id' => $space->id,
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('List the user\'s labels in a space. Use the ids with label_transaction or automation-rule label actions.')]
|
||||
class ListLabels extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$labels = Label::query()
|
||||
->forSpace($space)
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (Label $label): array => [
|
||||
'id' => $label->id,
|
||||
'name' => $label->name,
|
||||
'color' => $label->color,
|
||||
]);
|
||||
|
||||
return $this->json([
|
||||
'space_id' => $space->id,
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('List the spaces the user can access (personal and shared). Pass a space id to the other tools\' `space` argument to query a specific one.')]
|
||||
class ListSpaces extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, JsonSchema>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$spaces = $user->accessibleSpaces()
|
||||
->map(fn (Space $space): array => [
|
||||
'id' => $space->id,
|
||||
'name' => $space->name,
|
||||
'personal' => $space->personal,
|
||||
'is_current' => $space->id === $user->current_space_id,
|
||||
]);
|
||||
|
||||
return $this->json(['spaces' => $spaces]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\PlanFeature;
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
/**
|
||||
* Base for every Whisper Money read tool. Enforces the Pro-plan gate on each
|
||||
* call (a lapsed subscription stops working without revoking the token) and
|
||||
* provides the shared space-resolution and JSON-encoding helpers.
|
||||
*/
|
||||
abstract class McpTool extends Tool
|
||||
{
|
||||
/**
|
||||
* Expose snake_case tool names (search_transactions, list_spaces, …) instead
|
||||
* of the framework default kebab-case, matching the documented tool catalog.
|
||||
*/
|
||||
public function name(): string
|
||||
{
|
||||
return Str::snake(class_basename($this));
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return Response::error('Authentication required.');
|
||||
}
|
||||
|
||||
if (! $user->canUseFeature(PlanFeature::McpAccess)) {
|
||||
return Response::error(
|
||||
'A paid (Pro) plan is required to use the Whisper Money MCP. Upgrade your account at '.route('subscribe')
|
||||
);
|
||||
}
|
||||
|
||||
return $this->respond($request, $user);
|
||||
}
|
||||
|
||||
abstract protected function respond(Request $request, User $user): Response;
|
||||
|
||||
/**
|
||||
* Encode structured data as a JSON text response the agent can parse.
|
||||
*/
|
||||
protected function json(mixed $data): Response
|
||||
{
|
||||
return Response::text((string) json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuse an existing analytics controller by invoking one of its actions with
|
||||
* a synthesized GET request bound to the MCP user, returning its JSON body.
|
||||
* Keeps the (user-scoped) dashboard maths in exactly one place.
|
||||
*
|
||||
* ponytail: couples to the controllers returning a JsonResponse; acceptable
|
||||
* while they're stable. Extract the orchestration into a shared service if a
|
||||
* controller stops returning JSON or a third tool needs the same maths.
|
||||
*
|
||||
* @param array<string, mixed> $query
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
protected function callController(object $controller, string $method, User $user, array $query): array
|
||||
{
|
||||
$httpRequest = \Illuminate\Http\Request::create('/', 'GET', $query);
|
||||
$httpRequest->setUserResolver(fn (): User => $user);
|
||||
|
||||
return $controller->{$method}($httpRequest)->getData(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* The space a tool operates on: the optional `space` argument (validated
|
||||
* against the spaces the user can access) or the user's personal space.
|
||||
*
|
||||
* Scoping is by `space_id` only, gated by membership (`accessibleSpaces`): a
|
||||
* space is a shared tenant, so a member is meant to see every row in it. The
|
||||
* security boundary is the membership check here, not a per-row `user_id`
|
||||
* filter.
|
||||
*/
|
||||
protected function resolveSpace(Request $request, User $user): Space
|
||||
{
|
||||
$spaceId = $request->string('space')->toString();
|
||||
|
||||
if ($spaceId === '') {
|
||||
return $user->personalSpace ?? $user->activeSpace();
|
||||
}
|
||||
|
||||
$space = $user->accessibleSpaces()->firstWhere('id', $spaceId);
|
||||
|
||||
if ($space === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'space' => "You do not have access to a space with id {$spaceId}. Call list_spaces to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $space;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Search and filter the user\'s transactions by text, category, account, date range and amount. Amounts are integers in minor units (cents). Use this to analyse spending or to find recurring charges by grouping results by merchant.')]
|
||||
class SearchTransactions extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'query' => $schema->string()->description('Free text matched against description, creditor and debtor names.'),
|
||||
'account_id' => $schema->string()->description('Restrict to a single account id.'),
|
||||
'category_id' => $schema->string()->description('Restrict to a single category id.'),
|
||||
'from' => $schema->string()->description('Earliest transaction date, YYYY-MM-DD.'),
|
||||
'to' => $schema->string()->description('Latest transaction date, YYYY-MM-DD.'),
|
||||
'min_amount' => $schema->integer()->description('Minimum signed amount in minor units (cents).'),
|
||||
'max_amount' => $schema->integer()->description('Maximum signed amount in minor units (cents).'),
|
||||
'limit' => $schema->integer()->min(1)->max(200)->description('Max rows to return (default 50).'),
|
||||
'space' => $schema->string()->description('Space id to query. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'from' => ['sometimes', 'date'],
|
||||
'to' => ['sometimes', 'date'],
|
||||
'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->forSpace($space)
|
||||
->with(['account:id,name', 'category:id,name,type'])
|
||||
->when($request->string('query')->toString() !== '', function ($query) use ($request): void {
|
||||
$term = '%'.$request->string('query')->toString().'%';
|
||||
$query->where(function ($q) use ($term): void {
|
||||
$q->where('description', 'like', $term)
|
||||
->orWhere('creditor_name', 'like', $term)
|
||||
->orWhere('debtor_name', 'like', $term);
|
||||
});
|
||||
})
|
||||
->when($request->string('account_id')->toString() !== '', fn ($query) => $query->where('account_id', $request->string('account_id')->toString()))
|
||||
->when($request->string('category_id')->toString() !== '', fn ($query) => $query->where('category_id', $request->string('category_id')->toString()))
|
||||
->when($request->string('from')->toString() !== '', fn ($query) => $query->whereDate('transaction_date', '>=', $request->string('from')->toString()))
|
||||
->when($request->string('to')->toString() !== '', fn ($query) => $query->whereDate('transaction_date', '<=', $request->string('to')->toString()))
|
||||
->when($request->has('min_amount'), fn ($query) => $query->where('amount', '>=', $request->integer('min_amount')))
|
||||
->when($request->has('max_amount'), fn ($query) => $query->where('amount', '<=', $request->integer('max_amount')))
|
||||
->orderByDesc('transaction_date')
|
||||
->limit($request->integer('limit', 50))
|
||||
->get()
|
||||
->map(fn (Transaction $transaction): array => [
|
||||
'id' => $transaction->id,
|
||||
'date' => $transaction->transaction_date->toDateString(),
|
||||
'description' => $transaction->description,
|
||||
'amount' => $transaction->amount,
|
||||
'currency' => $transaction->currency_code,
|
||||
'category' => $transaction->category?->name,
|
||||
'category_id' => $transaction->category_id,
|
||||
'account' => $transaction->account?->name,
|
||||
'account_id' => $transaction->account_id,
|
||||
'source' => $transaction->source->value,
|
||||
'creditor_name' => $transaction->creditor_name,
|
||||
'debtor_name' => $transaction->debtor_name,
|
||||
]);
|
||||
|
||||
return $this->json([
|
||||
'space_id' => $space->id,
|
||||
'count' => $transactions->count(),
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\CategorySpendingService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
|
||||
|
||||
#[IsReadOnly]
|
||||
#[Description('Expense spending rolled up by category for a date range. Without parent_category_id, root categories are returned; pass one to drill into its children. Amounts are in minor units (cents). Covers the user\'s whole account.')]
|
||||
class SpendingByCategory extends McpTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'from' => $schema->string()->description('Start date, YYYY-MM-DD.')->required(),
|
||||
'to' => $schema->string()->description('End date, YYYY-MM-DD.')->required(),
|
||||
'parent_category_id' => $schema->string()->description('Drill into a parent category\'s children.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'from' => ['required', 'date'],
|
||||
'to' => ['required', 'date'],
|
||||
]);
|
||||
|
||||
$spending = app(CategorySpendingService::class)->forPeriod(
|
||||
$user->id,
|
||||
Carbon::parse($request->string('from')->toString()),
|
||||
Carbon::parse($request->string('to')->toString()),
|
||||
$request->string('parent_category_id')->toString() ?: null,
|
||||
);
|
||||
|
||||
return $this->json(['categories' => $spending->values()]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Mcp\Tools\Concerns\DecodesRulesJson;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit an automation rule. Only the fields you pass are changed. The rule must always keep at least one action (a category or labels). See create_automation_rule for the rules_json format.')]
|
||||
class UpdateAutomationRule extends WriteTool
|
||||
{
|
||||
use DecodesRulesJson;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'automation_rule_id' => $schema->string()->description('Id of the automation rule to edit.')->required(),
|
||||
'title' => $schema->string()->description('New rule name.'),
|
||||
'priority' => $schema->integer()->min(0)->description('New priority (lower is evaluated first).'),
|
||||
'rules_json' => $schema->object()->description('New JsonLogic condition object.'),
|
||||
'action_category_id' => $schema->string()->description('New category id to assign, or null to clear.'),
|
||||
'action_label_ids' => $schema->array()->items($schema->string())->description('Replacement set of label ids (replaces all existing labels).'),
|
||||
'action_note' => $schema->string()->description('New note to append, or null to clear.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'title' => ['sometimes', 'string', 'max:255'],
|
||||
'priority' => ['sometimes', 'integer', 'min:0'],
|
||||
'action_note' => ['sometimes', 'nullable', 'string'],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$rule = $this->ruleInSpace($request, $space);
|
||||
|
||||
if ($request->has('title')) {
|
||||
$rule->title = $request->string('title')->toString();
|
||||
}
|
||||
if ($request->has('priority')) {
|
||||
$rule->priority = $request->integer('priority');
|
||||
}
|
||||
if ($request->has('rules_json')) {
|
||||
$rule->rules_json = $this->rulesJson($request);
|
||||
}
|
||||
if ($request->has('action_category_id')) {
|
||||
$rule->action_category_id = $request->filled('action_category_id')
|
||||
? $this->categoryInSpace($request, $space, 'action_category_id')->id
|
||||
: null;
|
||||
}
|
||||
if ($request->has('action_note')) {
|
||||
$rule->action_note = $request->filled('action_note') ? $request->string('action_note')->toString() : null;
|
||||
}
|
||||
|
||||
$newLabels = $request->has('action_label_ids')
|
||||
? $this->labelsInSpace($request, $space, 'action_label_ids')
|
||||
: null;
|
||||
|
||||
// The rule must keep at least one action. Compare against the labels it
|
||||
// would have after this edit (the new set if provided, else current).
|
||||
$labelCount = $newLabels !== null ? $newLabels->count() : $rule->labels()->count();
|
||||
|
||||
if ($rule->action_category_id === null && $labelCount === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'action_category_id' => 'An automation rule must keep at least one action: a category or labels.',
|
||||
]);
|
||||
}
|
||||
|
||||
$rule->save();
|
||||
|
||||
if ($newLabels !== null) {
|
||||
$rule->labels()->sync($newLabels->pluck('id')->all());
|
||||
}
|
||||
|
||||
$rule->touch();
|
||||
|
||||
return $this->json(['automation_rule' => $this->presentAutomationRule($rule->refresh())]);
|
||||
}
|
||||
|
||||
private function ruleInSpace(Request $request, Space $space): AutomationRule
|
||||
{
|
||||
$id = $request->string('automation_rule_id')->toString();
|
||||
|
||||
$rule = AutomationRule::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($rule === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'automation_rule_id' => "No automation rule with id {$id} in space {$space->id}.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $rule;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Mcp\Tools\Concerns\ResolvesCategoryWrites;
|
||||
use App\Models\Category;
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use App\Services\CategoryTree;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit a category. Only the fields you pass are changed. Moving it under a parent (or clearing parent_id to make it a root) re-derives its type/cashflow and cascades the type to its descendants.')]
|
||||
class UpdateCategory extends WriteTool
|
||||
{
|
||||
use ResolvesCategoryWrites;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'category_id' => $schema->string()->description('Id of the category to edit.')->required(),
|
||||
'name' => $schema->string()->description('New category name.'),
|
||||
'icon' => $schema->string()->description('New icon name.'),
|
||||
'color' => $schema->string()->enum(array_column(CategoryColor::cases(), 'value'))->description('New category color.'),
|
||||
'type' => $schema->string()->enum(array_column(CategoryType::cases(), 'value'))->description('New type (ignored while the category has a parent).'),
|
||||
'parent_id' => $schema->string()->description('New parent id, or null to make it a root.'),
|
||||
'cashflow_direction' => $schema->string()->enum(array_column(CategoryCashflowDirection::cases(), 'value'))->description('Only used for transfer-type roots; otherwise derived automatically.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'icon' => ['sometimes', 'string'],
|
||||
'color' => ['sometimes', Rule::enum(CategoryColor::class)],
|
||||
'type' => ['sometimes', Rule::enum(CategoryType::class)],
|
||||
'cashflow_direction' => ['sometimes', Rule::enum(CategoryCashflowDirection::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$category = $this->categoryInSpace($request, $space);
|
||||
|
||||
$parent = $request->has('parent_id')
|
||||
? $this->resolveParentCategory($request, $space, $category)
|
||||
: $this->currentParent($category, $space);
|
||||
|
||||
$type = $parent !== null
|
||||
? $parent->type
|
||||
: ($request->has('type') ? $request->enum('type', CategoryType::class) : $category->type);
|
||||
|
||||
$requestedDirection = $request->filled('cashflow_direction')
|
||||
? $request->string('cashflow_direction')->toString()
|
||||
: $category->cashflow_direction->value;
|
||||
$cashflow = $this->cashflowDirectionFor($type, $parent, $requestedDirection);
|
||||
|
||||
$name = $request->has('name') ? $request->string('name')->toString() : $category->name;
|
||||
|
||||
if ($this->categoryNameTaken($space, $name, $parent?->id, $category->id)) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A category with that name already exists at this level.',
|
||||
]);
|
||||
}
|
||||
|
||||
$category->fill([
|
||||
'name' => $name,
|
||||
'icon' => $request->has('icon') ? $request->string('icon')->toString() : $category->icon,
|
||||
'color' => $request->has('color') ? $request->string('color')->toString() : $category->color,
|
||||
'type' => $type->value,
|
||||
'cashflow_direction' => $cashflow->value,
|
||||
'parent_id' => $parent?->id,
|
||||
]);
|
||||
$category->save();
|
||||
|
||||
(new CategoryTree)->syncDescendantTypes($category);
|
||||
|
||||
return $this->json(['category' => $this->presentCategory($category->refresh())]);
|
||||
}
|
||||
|
||||
private function currentParent(Category $category, Space $space): ?Category
|
||||
{
|
||||
if ($category->parent_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Category::query()->forSpace($space)->whereKey($category->parent_id)->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\LabelColor;
|
||||
use App\Models\Label;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit a label. Only the fields you pass are changed.')]
|
||||
class UpdateLabel extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'label_id' => $schema->string()->description('Id of the label to edit.')->required(),
|
||||
'name' => $schema->string()->description('New label name (unique within the space).'),
|
||||
'color' => $schema->string()->enum(array_column(LabelColor::cases(), 'value'))->description('New label color.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$request->validate([
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'color' => ['sometimes', Rule::enum(LabelColor::class)],
|
||||
]);
|
||||
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$label = $this->labelInSpace($request, $space);
|
||||
|
||||
if ($request->has('name')) {
|
||||
$name = $request->string('name')->toString();
|
||||
|
||||
$exists = Label::query()
|
||||
->forSpace($space)
|
||||
->where('name', $name)
|
||||
->whereKeyNot($label->id)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => 'A label with that name already exists.',
|
||||
]);
|
||||
}
|
||||
|
||||
$label->name = $name;
|
||||
}
|
||||
|
||||
if ($request->has('color')) {
|
||||
$label->color = $request->string('color')->toString();
|
||||
}
|
||||
|
||||
$label->save();
|
||||
|
||||
return $this->json(['label' => $this->presentLabel($label)]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Enums\CategorySource;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\User;
|
||||
use App\Services\ManualBalanceAdjuster;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
|
||||
|
||||
#[IsDestructive]
|
||||
#[Description('Edit a manually-created transaction. Only manual transactions can be edited; bank/imported ones keep their core fields locked (use categorize_transaction or label_transaction for those). Only the fields you pass are changed.')]
|
||||
class UpdateTransaction extends WriteTool
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'transaction_id' => $schema->string()->description('Id of the manually-created transaction to edit.')->required(),
|
||||
'description' => $schema->string()->description('New description.'),
|
||||
'amount' => $schema->integer()->description('New signed amount in minor units (cents).'),
|
||||
'transaction_date' => $schema->string()->description('New transaction date, YYYY-MM-DD.'),
|
||||
'currency_code' => $schema->string()->description('New ISO 4217 currency code (3 letters).'),
|
||||
'account_id' => $schema->string()->description('Move the transaction to another non-connected account.'),
|
||||
'category_id' => $schema->string()->description('New category id, or null to clear the category.'),
|
||||
'creditor_name' => $schema->string()->description('New creditor (payee) name.'),
|
||||
'debtor_name' => $schema->string()->description('New debtor (payer) name.'),
|
||||
'notes' => $schema->string()->description('New free-text notes.'),
|
||||
'update_balance' => $schema->boolean()->description('When true and the amount/date/account changed, move the manual account balance snapshots accordingly. Default false.'),
|
||||
'space' => $schema->string()->description('Space id. Defaults to the personal space.'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function write(Request $request, User $user): Response
|
||||
{
|
||||
$space = $this->resolveSpace($request, $user);
|
||||
$transaction = $this->transactionInSpace($request, $space);
|
||||
|
||||
if ($transaction->source !== TransactionSource::ManuallyCreated) {
|
||||
return Response::error('Only manually-created transactions can be edited. This one came from a bank or import, so its core fields are locked. Use categorize_transaction or label_transaction instead.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'description' => ['sometimes', 'string'],
|
||||
'amount' => ['sometimes', 'integer'],
|
||||
'transaction_date' => ['sometimes', 'date'],
|
||||
'currency_code' => ['sometimes', 'string', 'size:3'],
|
||||
'notes' => ['sometimes', 'nullable', 'string'],
|
||||
'creditor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'debtor_name' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
// Snapshot the pre-edit account/date/amount so a manual balance can be
|
||||
// moved off the old values if the edit changes them.
|
||||
$originalSnapshot = clone $transaction;
|
||||
|
||||
if ($request->has('description')) {
|
||||
$transaction->description = $request->string('description')->toString();
|
||||
}
|
||||
if ($request->has('amount')) {
|
||||
$transaction->amount = $request->integer('amount');
|
||||
}
|
||||
if ($request->has('transaction_date')) {
|
||||
$transaction->transaction_date = Carbon::parse($request->string('transaction_date')->toString());
|
||||
}
|
||||
if ($request->has('currency_code')) {
|
||||
$transaction->currency_code = mb_strtoupper($request->string('currency_code')->toString());
|
||||
}
|
||||
if ($request->has('account_id')) {
|
||||
$transaction->account_id = $this->writableAccount($request, $space)->id;
|
||||
}
|
||||
if ($request->has('notes')) {
|
||||
$transaction->notes = $request->filled('notes') ? $request->string('notes')->toString() : null;
|
||||
}
|
||||
if ($request->has('creditor_name')) {
|
||||
$transaction->creditor_name = $request->filled('creditor_name') ? $request->string('creditor_name')->toString() : null;
|
||||
}
|
||||
if ($request->has('debtor_name')) {
|
||||
$transaction->debtor_name = $request->filled('debtor_name') ? $request->string('debtor_name')->toString() : null;
|
||||
}
|
||||
|
||||
// A new category is always a manual assignment: reset any AI/rule
|
||||
// provenance so the row is not later treated as machine-categorized.
|
||||
// ponytail: unlike the web edit path this does not learn a correction
|
||||
// rule — MCP writes stay predictable and side-effect free.
|
||||
if ($request->has('category_id')) {
|
||||
$newCategoryId = $request->filled('category_id') ? $this->categoryInSpace($request, $space)->id : null;
|
||||
|
||||
if ($newCategoryId !== $transaction->category_id) {
|
||||
$transaction->category_id = $newCategoryId;
|
||||
$transaction->category_source = $newCategoryId === null ? null : CategorySource::Manual;
|
||||
$transaction->ai_confidence = null;
|
||||
$transaction->categorized_by_rule_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
$transaction->save();
|
||||
|
||||
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
|
||||
$adjuster = app(ManualBalanceAdjuster::class);
|
||||
$adjuster->reverseDeletedTransaction($originalSnapshot);
|
||||
$adjuster->applyCreatedTransaction($transaction->load('account'));
|
||||
}
|
||||
|
||||
return $this->json(['transaction' => $this->presentTransaction($transaction->refresh())]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mcp\Tools;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\Space;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
|
||||
/**
|
||||
* Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate
|
||||
* it gates write access: OAuth connections (Claude Desktop / ChatGPT) get
|
||||
* read+write, and Sanctum personal access tokens must carry the `mcp:write`
|
||||
* ability, so a read-only PAT can analyse data but never change it.
|
||||
*
|
||||
* Each concrete write tool must additionally carry the #[IsDestructive]
|
||||
* annotation. PHP attributes are not inherited, so the framework only reports
|
||||
* one declared directly on the served tool class — it cannot live here.
|
||||
*/
|
||||
abstract class WriteTool extends McpTool
|
||||
{
|
||||
protected function respond(Request $request, User $user): Response
|
||||
{
|
||||
// Write access is granted to OAuth connections (Claude Desktop /
|
||||
// ChatGPT, resolved via the `api` guard — the user approves the
|
||||
// connection on the consent screen) and to Sanctum personal access
|
||||
// tokens carrying the mcp:write ability. A read-only Sanctum token is
|
||||
// rejected. Bank-connected data stays protected for both (see the
|
||||
// writableAccount / transaction helpers below).
|
||||
if (Auth::getDefaultDriver() !== 'api' && ! $user->tokenCan('mcp:write')) {
|
||||
return Response::error('This token is read-only. Create a read & write token to make changes.');
|
||||
}
|
||||
|
||||
return $this->write($request, $user);
|
||||
}
|
||||
|
||||
abstract protected function write(Request $request, User $user): Response;
|
||||
|
||||
/**
|
||||
* Resolve an account the token may write to: it must live in the space and
|
||||
* must not be connected to a bank (bank-sourced data is never touched).
|
||||
*/
|
||||
protected function writableAccount(Request $request, Space $space, string $key = 'account_id'): Account
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$account = Account::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($account === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No account with id {$id} in space {$space->id}. Call list_accounts to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
if ($account->isConnected()) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => 'That account is connected to a bank and is read-only. Only non-connected (manual) accounts can be written to.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
protected function transactionInSpace(Request $request, Space $space, string $key = 'transaction_id'): Transaction
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$transaction = Transaction::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($transaction === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No transaction with id {$id} in space {$space->id}. Call search_transactions to find ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
protected function categoryInSpace(Request $request, Space $space, string $key = 'category_id'): Category
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$category = Category::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($category === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No category with id {$id} in space {$space->id}. Call list_categories to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $category;
|
||||
}
|
||||
|
||||
protected function labelInSpace(Request $request, Space $space, string $key = 'label_id'): Label
|
||||
{
|
||||
$id = $request->string($key)->toString();
|
||||
|
||||
$label = Label::query()->forSpace($space)->whereKey($id)->first();
|
||||
|
||||
if ($label === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "No label with id {$id} in space {$space->id}. Call list_labels to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every label id passed under $key, asserting each belongs to the
|
||||
* space. Returns an empty collection when the argument is absent or empty.
|
||||
*
|
||||
* @return Collection<int, Label>
|
||||
*/
|
||||
protected function labelsInSpace(Request $request, Space $space, string $key): Collection
|
||||
{
|
||||
$ids = collect($request->get($key, []))
|
||||
->map(fn (mixed $id): string => (string) $id)
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
/** @var Collection<int, Label> $empty */
|
||||
$empty = Label::query()->whereRaw('1 = 0')->get();
|
||||
|
||||
return $empty;
|
||||
}
|
||||
|
||||
$labels = Label::query()->forSpace($space)->whereIn('id', $ids)->get();
|
||||
|
||||
if ($labels->count() !== $ids->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => "One or more label ids do not exist in space {$space->id}. Call list_labels to see valid ids.",
|
||||
]);
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transaction shape returned by every transaction write tool, matching
|
||||
* the fields search_transactions exposes so the agent sees a familiar row.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentTransaction(Transaction $transaction): array
|
||||
{
|
||||
$transaction->loadMissing(['account:id,name', 'category:id,name', 'labels:id,name']);
|
||||
|
||||
return [
|
||||
'id' => $transaction->id,
|
||||
'date' => $transaction->transaction_date->toDateString(),
|
||||
'description' => $transaction->description,
|
||||
'amount' => $transaction->amount,
|
||||
'currency' => $transaction->currency_code,
|
||||
'category_id' => $transaction->category_id,
|
||||
'category' => $transaction->category?->name,
|
||||
'category_source' => $transaction->category_source?->value,
|
||||
'account_id' => $transaction->account_id,
|
||||
'account' => $transaction->account?->name,
|
||||
'source' => $transaction->source->value,
|
||||
'creditor_name' => $transaction->creditor_name,
|
||||
'debtor_name' => $transaction->debtor_name,
|
||||
'labels' => $transaction->labels
|
||||
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentCategory(Category $category): array
|
||||
{
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'name' => $category->name,
|
||||
'icon' => $category->icon,
|
||||
'color' => $category->color,
|
||||
'type' => $category->type->value,
|
||||
'cashflow_direction' => $category->cashflow_direction->value,
|
||||
'parent_id' => $category->parent_id,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentLabel(Label $label): array
|
||||
{
|
||||
return [
|
||||
'id' => $label->id,
|
||||
'name' => $label->name,
|
||||
'color' => $label->color,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function presentAutomationRule(AutomationRule $rule): array
|
||||
{
|
||||
$rule->loadMissing('labels:id,name');
|
||||
|
||||
return [
|
||||
'id' => $rule->id,
|
||||
'title' => $rule->title,
|
||||
'priority' => $rule->priority,
|
||||
'rules_json' => $rule->rules_json,
|
||||
'action_category_id' => $rule->action_category_id,
|
||||
'action_note' => $rule->action_note,
|
||||
'origin' => $rule->origin->value,
|
||||
'labels' => $rule->labels
|
||||
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name])
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\AccountType;
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use Database\Factories\AccountFactory;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
class Account extends Model
|
||||
{
|
||||
/** @use HasFactory<AccountFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'name',
|
||||
'name_iv',
|
||||
'bank_id',
|
||||
|
|
@ -40,6 +42,7 @@ class Account extends Model
|
|||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'bank_id',
|
||||
'iban',
|
||||
'position',
|
||||
|
|
@ -101,6 +104,12 @@ class Account extends Model
|
|||
return $this->hasMany(AccountBalance::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<AccountImportConfig, $this> */
|
||||
public function importConfigs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AccountImportConfig::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<BankingConnection, $this> */
|
||||
public function bankingConnection(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\ImportConfigType;
|
||||
use Database\Factories\AccountImportConfigFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AccountImportConfig extends Model
|
||||
{
|
||||
/** @use HasFactory<AccountImportConfigFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'type',
|
||||
'config',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'type' => ImportConfigType::class,
|
||||
'config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Account, $this> */
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\RuleOrigin;
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use Database\Factories\AutomationRuleFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
class AutomationRule extends Model
|
||||
{
|
||||
/** @use HasFactory<AutomationRuleFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'title',
|
||||
'priority',
|
||||
'origin',
|
||||
|
|
@ -32,6 +34,11 @@ class AutomationRule extends Model
|
|||
'action_note_iv',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'space_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
|
|
@ -62,6 +69,7 @@ class AutomationRule extends Model
|
|||
return $this->belongsTo(Category::class, 'action_category_id');
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Label, $this, AutomationRuleLabel, 'pivot'> */
|
||||
public function labels(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Label::class, 'automation_rule_labels')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use Carbon\Carbon;
|
||||
use Database\Factories\BankingConnectionFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
@ -28,10 +29,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
class BankingConnection extends Model
|
||||
{
|
||||
/** @use HasFactory<BankingConnectionFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'provider',
|
||||
'authorization_id',
|
||||
'state_token',
|
||||
|
|
@ -52,6 +54,7 @@ class BankingConnection extends Model
|
|||
];
|
||||
|
||||
protected $hidden = [
|
||||
'space_id',
|
||||
'api_token',
|
||||
'api_secret',
|
||||
'pending_accounts_data',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
use App\Enums\BudgetPeriodType;
|
||||
use App\Enums\RolloverType;
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -18,10 +19,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
*/
|
||||
class Budget extends Model
|
||||
{
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'name',
|
||||
'period_type',
|
||||
'period_start_day',
|
||||
|
|
@ -32,6 +34,7 @@ class Budget extends Model
|
|||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'period_duration',
|
||||
'space_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use Database\Factories\CategoryFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
|
|
@ -26,7 +27,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
class Category extends Model
|
||||
{
|
||||
/** @use HasFactory<CategoryFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Maximum allowed nesting depth (a root counts as level 1).
|
||||
|
|
@ -40,12 +41,14 @@ class Category extends Model
|
|||
'type',
|
||||
'cashflow_direction',
|
||||
'user_id',
|
||||
'space_id',
|
||||
'parent_id',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Marks a model as belonging to a Space (the tenant). On create, `space_id` is
|
||||
* filled from the row's own `user_id` (its owner's current space) unless it was
|
||||
* set explicitly — so factories and existing single-owner creation paths keep
|
||||
* working untouched, while multi-space callers pass `space_id` themselves.
|
||||
*
|
||||
* @property ?string $space_id
|
||||
*/
|
||||
trait BelongsToSpace
|
||||
{
|
||||
public static function bootBelongsToSpace(): void
|
||||
{
|
||||
static::creating(function ($model): void {
|
||||
if ($model->space_id === null) {
|
||||
$model->space_id = $model->resolveDefaultSpaceId();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Space, $this> */
|
||||
public function space(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Space::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict a query to a single space.
|
||||
*
|
||||
* @param Builder<static> $query
|
||||
* @return Builder<static>
|
||||
*/
|
||||
public function scopeForSpace(Builder $query, Space|string $space): Builder
|
||||
{
|
||||
return $query->where($this->getTable().'.space_id', $space instanceof Space ? $space->id : $space);
|
||||
}
|
||||
|
||||
/**
|
||||
* The space a new row defaults to when none was set explicitly: the current
|
||||
* space of the user that owns the row. Models with a stronger anchor (e.g. a
|
||||
* transaction inheriting its account's space) override this.
|
||||
*/
|
||||
protected function resolveDefaultSpaceId(): ?string
|
||||
{
|
||||
return $this->spaceIdFromUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* The current space of the user that owns this row, if any.
|
||||
*/
|
||||
protected function spaceIdFromUser(): ?string
|
||||
{
|
||||
$userId = $this->getAttribute('user_id');
|
||||
|
||||
if ($userId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::query()->whereKey($userId)->value('current_space_id');
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use Database\Factories\LabelFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -13,12 +14,13 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
class Label extends Model
|
||||
{
|
||||
/** @use HasFactory<LabelFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
'user_id',
|
||||
'space_id',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -29,6 +31,7 @@ class Label extends Model
|
|||
*/
|
||||
protected $hidden = [
|
||||
'pivot',
|
||||
'space_id',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Enums\AnalysisMode;
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use Database\Factories\SavedFilterFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
|
@ -12,10 +13,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
class SavedFilter extends Model
|
||||
{
|
||||
/** @use HasFactory<SavedFilterFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
use BelongsToSpace, HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'name',
|
||||
'filters',
|
||||
'analysis_days',
|
||||
|
|
@ -25,6 +27,7 @@ class SavedFilter extends Model
|
|||
/** @var list<string> */
|
||||
protected $hidden = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SpaceFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $owner_id
|
||||
* @property string $name
|
||||
* @property bool $personal
|
||||
*/
|
||||
class Space extends Model
|
||||
{
|
||||
/** @use HasFactory<SpaceFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'owner_id',
|
||||
'name',
|
||||
'personal',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'personal' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Members invited into the space (excludes the owner, who is implicit).
|
||||
*
|
||||
* @return BelongsToMany<User, $this>
|
||||
*/
|
||||
public function members(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'space_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/** @return HasMany<SpaceInvitation, $this> */
|
||||
public function invitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(SpaceInvitation::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<Account, $this> */
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(Account::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<BankingConnection, $this> */
|
||||
public function bankingConnections(): HasMany
|
||||
{
|
||||
return $this->hasMany(BankingConnection::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<Transaction, $this> */
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<Category, $this> */
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<Label, $this> */
|
||||
public function labels(): HasMany
|
||||
{
|
||||
return $this->hasMany(Label::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<Budget, $this> */
|
||||
public function budgets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Budget::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<AutomationRule, $this> */
|
||||
public function automationRules(): HasMany
|
||||
{
|
||||
return $this->hasMany(AutomationRule::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<SavedFilter, $this> */
|
||||
public function savedFilters(): HasMany
|
||||
{
|
||||
return $this->hasMany(SavedFilter::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given user owns or is a member of this space. The owner check
|
||||
* short-circuits without a query for the common (personal-space) case.
|
||||
*/
|
||||
public function hasMember(User $user): bool
|
||||
{
|
||||
if ($this->owner_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->members()->whereKey($user->id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* The role the user holds in this space: owner, the pivot role, or null when
|
||||
* they have no access.
|
||||
*/
|
||||
public function roleFor(User $user): ?string
|
||||
{
|
||||
if ($this->owner_id === $user->id) {
|
||||
return 'owner';
|
||||
}
|
||||
|
||||
return $this->members()->whereKey($user->id)->value('role');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $space_id
|
||||
* @property ?string $invited_by_id
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $token
|
||||
* @property ?Carbon $expires_at
|
||||
* @property ?Carbon $accepted_at
|
||||
*/
|
||||
class SpaceInvitation extends Model
|
||||
{
|
||||
use HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'space_id',
|
||||
'invited_by_id',
|
||||
'email',
|
||||
'role',
|
||||
'token',
|
||||
'expires_at',
|
||||
'accepted_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
'accepted_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Space, $this> */
|
||||
public function space(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Space::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_id');
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isAccepted(): bool
|
||||
{
|
||||
return $this->accepted_at !== null;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use App\Enums\TransactionSource;
|
|||
use App\Events\TransactionCreated;
|
||||
use App\Events\TransactionDeleted;
|
||||
use App\Events\TransactionUpdated;
|
||||
use App\Models\Concerns\BelongsToSpace;
|
||||
use App\Services\CategoryTree;
|
||||
use Carbon\Carbon;
|
||||
use Database\Factories\TransactionFactory;
|
||||
|
|
@ -25,6 +26,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
/**
|
||||
* @property Carbon $transaction_date
|
||||
* @property int|float $total_amount
|
||||
* @property TransactionSource $source
|
||||
* @property ?CategorySource $category_source
|
||||
* @property ?float $ai_confidence
|
||||
* @property ?string $categorized_by_rule_id
|
||||
|
|
@ -35,7 +37,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||
class Transaction extends Model
|
||||
{
|
||||
/** @use HasFactory<TransactionFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
use BelongsToSpace, HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
/** @var array<string, class-string> */
|
||||
protected $dispatchesEvents = [
|
||||
|
|
@ -46,6 +48,7 @@ class Transaction extends Model
|
|||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'space_id',
|
||||
'account_id',
|
||||
'category_id',
|
||||
'category_source',
|
||||
|
|
@ -77,6 +80,7 @@ class Transaction extends Model
|
|||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'space_id',
|
||||
'original_description',
|
||||
'external_transaction_id',
|
||||
'dedup_fingerprint',
|
||||
|
|
@ -111,6 +115,26 @@ class Transaction extends Model
|
|||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* A transaction always lives in its account's space (the account is the
|
||||
* tenant anchor), so bank-sync inserts land in the right space regardless of
|
||||
* whichever space the syncing user is currently viewing.
|
||||
*/
|
||||
protected function resolveDefaultSpaceId(): ?string
|
||||
{
|
||||
$accountId = $this->getAttribute('account_id');
|
||||
|
||||
if ($accountId !== null) {
|
||||
$spaceId = Account::query()->whereKey($accountId)->value('space_id');
|
||||
|
||||
if ($spaceId !== null) {
|
||||
return $spaceId;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->spaceIdFromUser();
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Category, $this> */
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,8 +10,11 @@ use Database\Factories\UserFactory;
|
|||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Contracts\Translation\HasLocalePreference;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
|
@ -23,6 +26,7 @@ use Illuminate\Support\Facades\Log;
|
|||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Pennant\Concerns\HasFeatures;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* @property ?Carbon $last_logged_in_at
|
||||
|
|
@ -33,7 +37,7 @@ use Laravel\Pennant\Concerns\HasFeatures;
|
|||
class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
use Billable, HasApiTokens, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -50,6 +54,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
'currency_code',
|
||||
'locale',
|
||||
'timezone',
|
||||
'current_space_id',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -89,6 +94,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized active space for the current request lifecycle.
|
||||
*/
|
||||
protected ?Space $resolvedActiveSpace = null;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::created(function (User $user): void {
|
||||
$user->provisionPersonalSpace();
|
||||
});
|
||||
}
|
||||
|
||||
public function isOnboarded(): bool
|
||||
{
|
||||
return $this->onboarded_at !== null;
|
||||
|
|
@ -139,6 +156,97 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
});
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Space, $this> */
|
||||
public function currentSpace(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Space::class, 'current_space_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Space, $this> */
|
||||
public function ownedSpaces(): HasMany
|
||||
{
|
||||
return $this->hasMany(Space::class, 'owner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* The one personal space every user owns (created on registration).
|
||||
*
|
||||
* @return HasOne<Space, $this>
|
||||
*/
|
||||
public function personalSpace(): HasOne
|
||||
{
|
||||
return $this->hasOne(Space::class, 'owner_id')->where('personal', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spaces the user was invited into (excludes the ones they own).
|
||||
*
|
||||
* @return BelongsToMany<Space, $this>
|
||||
*/
|
||||
public function memberSpaces(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Space::class, 'space_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Every space the user can access: the ones they own plus the ones they were
|
||||
* invited into, ordered with the personal space first.
|
||||
*
|
||||
* @return Collection<int, Space>
|
||||
*/
|
||||
public function accessibleSpaces(): Collection
|
||||
{
|
||||
return Space::query()
|
||||
->where('owner_id', $this->id)
|
||||
->orWhereHas('members', fn (Builder $query) => $query->whereKey($this->id))
|
||||
->orderByDesc('personal')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotently ensure the user has a personal space and points at it.
|
||||
*/
|
||||
public function provisionPersonalSpace(): Space
|
||||
{
|
||||
$space = $this->ownedSpaces()->firstOrCreate(
|
||||
['personal' => true],
|
||||
['name' => 'Personal'],
|
||||
);
|
||||
|
||||
if ($this->current_space_id === null) {
|
||||
$this->forceFill(['current_space_id' => $space->id])->saveQuietly();
|
||||
}
|
||||
|
||||
return $space;
|
||||
}
|
||||
|
||||
/**
|
||||
* The space the user is currently working in. Falls back to (and repairs
|
||||
* towards) the personal space when the pointer is missing or points at a
|
||||
* space the user can no longer access — e.g. after a membership is revoked or
|
||||
* a Business subscription lapses.
|
||||
*/
|
||||
public function activeSpace(): Space
|
||||
{
|
||||
if ($this->resolvedActiveSpace !== null) {
|
||||
return $this->resolvedActiveSpace;
|
||||
}
|
||||
|
||||
$space = $this->current_space_id !== null
|
||||
? Space::query()->find($this->current_space_id)
|
||||
: null;
|
||||
|
||||
if ($space === null || ! $space->hasMember($this)) {
|
||||
$space = $this->provisionPersonalSpace();
|
||||
$this->forceFill(['current_space_id' => $space->id])->saveQuietly();
|
||||
}
|
||||
|
||||
return $this->resolvedActiveSpace = $space;
|
||||
}
|
||||
|
||||
/** @return HasMany<AutomationRule, $this> */
|
||||
public function automationRules(): HasMany
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use Illuminate\Support\Facades\RateLimiter;
|
|||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -55,5 +56,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
RateLimiter::for('emails', function (object $job): Limit {
|
||||
return Limit::perSecond(30);
|
||||
});
|
||||
|
||||
// Render the OAuth consent screen (Claude Desktop / ChatGPT connecting
|
||||
// to the MCP server) with our own on-brand Blade view.
|
||||
Passport::authorizationView(fn (array $parameters) => response()->view('mcp.authorize', $parameters));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ namespace App\Services\Ai;
|
|||
|
||||
use App\Ai\Agents\RuleSuggestionAgent;
|
||||
use App\Services\Ai\Contracts\RuleSuggestionGenerator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Ai\Enums\Lab;
|
||||
use Laravel\Ai\Exceptions\FailoverableException;
|
||||
use Throwable;
|
||||
|
||||
class LaravelAiRuleSuggestionGenerator implements RuleSuggestionGenerator
|
||||
|
|
@ -27,6 +29,17 @@ class LaravelAiRuleSuggestionGenerator implements RuleSuggestionGenerator
|
|||
foreach ($this->generateBatchWithRetry($batch, $categoryOptions) as $suggestion) {
|
||||
$suggestions[] = $suggestion;
|
||||
}
|
||||
} catch (FailoverableException $exception) {
|
||||
// An overloaded or rate-limited provider is an expected transient
|
||||
// condition, not a bug (PHP-LARAVEL-44). Count it as a failure so
|
||||
// an all-transient run still surfaces, but don't report the
|
||||
// per-batch noise — a genuine total failure is still reported once
|
||||
// by the run-level handler. Mirrors CategorizeTransactions.
|
||||
$failures++;
|
||||
$lastError = $exception;
|
||||
Log::warning('AI rule-suggestion batch dropped: provider transient failure.', [
|
||||
'exception' => $exception->getMessage(),
|
||||
]);
|
||||
} catch (Throwable $exception) {
|
||||
// A single batch failing must not discard the suggestions from
|
||||
// the batches that did succeed (a run can span many batches).
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Contracts\BankingProviderInterface;
|
|||
use App\Exceptions\Banking\ExpiredBankingSessionException;
|
||||
use App\Exceptions\Banking\InaccessibleBankAccountException;
|
||||
use App\Exceptions\Banking\TransientBankingProviderException;
|
||||
use App\Exceptions\Banking\WrongTransactionsPeriodException;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
|
|
@ -119,6 +120,22 @@ class EnableBankingProvider implements BankingProviderInterface
|
|||
);
|
||||
}
|
||||
|
||||
if ($this->isWrongPeriod($e)) {
|
||||
throw new WrongTransactionsPeriodException(
|
||||
'EnableBanking rejected the requested transactions period as too wide.',
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
|
||||
if ($this->isTransientServerError($e)) {
|
||||
throw new TransientBankingProviderException(
|
||||
'EnableBanking returned a server error while fetching account transactions.',
|
||||
provider: 'enablebanking',
|
||||
statusCode: $e->response->status(),
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->isAspspError($e)) {
|
||||
throw $e;
|
||||
}
|
||||
|
|
@ -170,6 +187,15 @@ class EnableBankingProvider implements BankingProviderInterface
|
|||
);
|
||||
}
|
||||
|
||||
if ($this->isTransientServerError($e)) {
|
||||
throw new TransientBankingProviderException(
|
||||
'EnableBanking returned a server error while fetching account balances.',
|
||||
provider: 'enablebanking',
|
||||
statusCode: $e->response->status(),
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->isAspspError($e)) {
|
||||
throw $e;
|
||||
}
|
||||
|
|
@ -222,6 +248,14 @@ class EnableBankingProvider implements BankingProviderInterface
|
|||
&& ($body['error'] ?? null) === 'ASPSP_ERROR';
|
||||
}
|
||||
|
||||
private function isTransientServerError(RequestException $e): bool
|
||||
{
|
||||
// Any upstream 5xx (EnableBanking itself or the ASPSP behind it) is a
|
||||
// transient server-side failure — same class as a ConnectionException,
|
||||
// so retry/self-heal rather than report it as an app error.
|
||||
return $e->response->status() >= 500;
|
||||
}
|
||||
|
||||
private function isExpiredSession(RequestException $e): bool
|
||||
{
|
||||
$body = $this->errorBody($e);
|
||||
|
|
@ -243,6 +277,20 @@ class EnableBankingProvider implements BankingProviderInterface
|
|||
&& $errorName === 'AccountNotAccessibleException';
|
||||
}
|
||||
|
||||
private function isWrongPeriod(RequestException $e): bool
|
||||
{
|
||||
$message = $this->errorBody($e)['message'] ?? null;
|
||||
|
||||
// The bank refused the requested date range as too wide ("Wrong
|
||||
// transactions period requested"). Keyed on 422 + the stable "period"
|
||||
// token so genuine validation 422s (e.g. malformed dates) still surface.
|
||||
// ponytail: message match; if EnableBanking adds a stable error code for
|
||||
// this, key on that instead.
|
||||
return $e->response->status() === 422
|
||||
&& is_string($message)
|
||||
&& str_contains(strtolower($message), 'period');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
|
@ -264,7 +312,8 @@ class EnableBankingProvider implements BankingProviderInterface
|
|||
$body = $response->json();
|
||||
$error = is_array($body) ? ($body['error'] ?? null) : null;
|
||||
$isExpected = ($response->status() === 400 && $error === 'ASPSP_ERROR')
|
||||
|| ($response->status() === 401 && $error === 'EXPIRED_SESSION');
|
||||
|| ($response->status() === 401 && $error === 'EXPIRED_SESSION')
|
||||
|| $response->status() === 422;
|
||||
|
||||
Log::log($isExpected ? 'warning' : 'error', 'EnableBanking API error', [
|
||||
'status' => $response->status(),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Services\Banking\Sync;
|
||||
|
||||
use App\Exceptions\Banking\InaccessibleBankAccountException;
|
||||
use App\Exceptions\Banking\WrongTransactionsPeriodException;
|
||||
use App\Jobs\SendDailyBankTransactionsSyncedEmailJob;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Services\Banking\BalanceSyncService;
|
||||
|
|
@ -63,13 +64,15 @@ class EnableBankingSyncer extends AbstractBankingConnectionSyncer
|
|||
$this->balanceSync->calculateHistoricalBalances($account);
|
||||
}
|
||||
}
|
||||
} catch (InaccessibleBankAccountException) {
|
||||
// A single account the bank no longer exposes must not break the
|
||||
} catch (InaccessibleBankAccountException|WrongTransactionsPeriodException $e) {
|
||||
// A single account the bank no longer exposes, or whose history
|
||||
// window it refuses even after narrowing, must not break the
|
||||
// whole connection sync. Skip it; the user can stop syncing it
|
||||
// from the manage-accounts screen.
|
||||
Log::warning('Skipping inaccessible EnableBanking account during sync', [
|
||||
Log::warning('Skipping unsyncable EnableBanking account during sync', [
|
||||
'connection_id' => $connection->id,
|
||||
'account_id' => $account->id,
|
||||
'reason' => $e::class,
|
||||
]);
|
||||
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ namespace App\Services\Banking;
|
|||
/**
|
||||
* Builds a deterministic fingerprint for an EnableBanking transaction
|
||||
* payload so we can dedup even when the upstream bank omits a stable
|
||||
* id (transaction_id / entry_reference).
|
||||
*
|
||||
* Shared between the live sync path and the cleanup command so they
|
||||
* stay in lock-step.
|
||||
* id (transaction_id / entry_reference), consumed by TransactionSyncService.
|
||||
*/
|
||||
class TransactionFingerprint
|
||||
{
|
||||
|
|
@ -21,8 +18,24 @@ class TransactionFingerprint
|
|||
return self::hash(['transaction_id', $data['transaction_id']]);
|
||||
}
|
||||
|
||||
if (($data['entry_reference'] ?? null) !== null) {
|
||||
return self::hash(['entry_reference', $data['entry_reference']]);
|
||||
$entryReference = $data['entry_reference'] ?? null;
|
||||
|
||||
// Some ASPSPs emit a positional `{booking_date}.{index}` entry_reference
|
||||
// that is absent the day a transaction first appears and only populated
|
||||
// on a later sync. Keying on it fingerprints the same transaction
|
||||
// differently across syncs, so it slips past dedup and imports twice.
|
||||
// Treat that positional form as "no stable id" and fall through to the
|
||||
// content hash, which is identical on both syncs.
|
||||
//
|
||||
// Trade-off: the index is also the only field that would tell apart two
|
||||
// genuinely distinct same-day transactions with byte-identical content
|
||||
// (e.g. two identical tolls). Dropping it collapses them to one
|
||||
// fingerprint, so only the first is kept. We accept that here — a rare
|
||||
// silent under-count over the systematic duplication it fixes. Fixing
|
||||
// both needs occurrence-aware dedup in the consumer (a schema change),
|
||||
// tracked as a follow-up.
|
||||
if ($entryReference !== null && ! self::isPositionalReference($entryReference)) {
|
||||
return self::hash(['entry_reference', $entryReference]);
|
||||
}
|
||||
|
||||
return self::hash([
|
||||
|
|
@ -43,6 +56,11 @@ class TransactionFingerprint
|
|||
]);
|
||||
}
|
||||
|
||||
private static function isPositionalReference(string $reference): bool
|
||||
{
|
||||
return preg_match('/^\d{4}-\d{2}-\d{2}\.\d+$/D', $reference) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|string $remittance
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,12 +4,24 @@ namespace App\Services\Banking;
|
|||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Exceptions\Banking\WrongTransactionsPeriodException;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TransactionSyncService
|
||||
{
|
||||
/**
|
||||
* Fallback lookback windows (in days back from date_to) tried in order when
|
||||
* the bank rejects the requested transactions period as too wide. Ordered
|
||||
* widest-first so the user keeps as much history as the bank will serve;
|
||||
* the last step is the floor before the account is skipped.
|
||||
*
|
||||
* @var list<int>
|
||||
*/
|
||||
private const array WRONG_PERIOD_LOOKBACK_DAYS = [90, 30, 7];
|
||||
|
||||
public function __construct(
|
||||
private BankingProviderInterface $provider,
|
||||
private TransactionDescriptionFormatter $descriptionFormatter,
|
||||
|
|
@ -40,27 +52,59 @@ class TransactionSyncService
|
|||
// ever dwarfs its sync window, narrow this to the incoming batch's keys.
|
||||
[$knownFingerprints, $knownExternalIds] = $this->loadExistingDedupKeys($account);
|
||||
|
||||
do {
|
||||
$result = $this->provider->getTransactions(
|
||||
$account->external_account_id,
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$continuationKey,
|
||||
$strategy,
|
||||
);
|
||||
// The bank can reject the requested window as too wide (HTTP 422). When
|
||||
// that happens, restart the account from the first page with a
|
||||
// progressively narrower window so the user still gets the history the
|
||||
// bank is willing to serve, instead of crashing the whole connection
|
||||
// sync. Re-fetched pages are idempotent (dedup skips already-imported
|
||||
// rows; daily balances are keyed by date), and strategy is dropped on
|
||||
// the narrowed retry so the explicit date_from is honoured rather than
|
||||
// overridden by "longest".
|
||||
while (true) {
|
||||
try {
|
||||
$continuationKey = null;
|
||||
|
||||
foreach ($result['transactions'] as $transaction) {
|
||||
if ($this->importTransaction($account, $transaction, $bankName, $knownFingerprints, $knownExternalIds)) {
|
||||
$created++;
|
||||
do {
|
||||
$result = $this->provider->getTransactions(
|
||||
$account->external_account_id,
|
||||
$dateFrom,
|
||||
$dateTo,
|
||||
$continuationKey,
|
||||
$strategy,
|
||||
);
|
||||
|
||||
foreach ($result['transactions'] as $transaction) {
|
||||
if ($this->importTransaction($account, $transaction, $bankName, $knownFingerprints, $knownExternalIds)) {
|
||||
$created++;
|
||||
}
|
||||
|
||||
if ($saveDailyBalances) {
|
||||
$this->trackDailyBalance($transaction, $dailyBalances);
|
||||
}
|
||||
}
|
||||
|
||||
$continuationKey = $result['continuation_key'];
|
||||
} while ($continuationKey);
|
||||
|
||||
break;
|
||||
} catch (WrongTransactionsPeriodException $e) {
|
||||
$narrowedDateFrom = $this->nextNarrowerDateFrom($dateFrom, $dateTo);
|
||||
|
||||
if ($narrowedDateFrom === null) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($saveDailyBalances) {
|
||||
$this->trackDailyBalance($transaction, $dailyBalances);
|
||||
}
|
||||
Log::warning('EnableBanking rejected the transactions period; retrying with a narrower window', [
|
||||
'account_id' => $account->id,
|
||||
'rejected_date_from' => $dateFrom,
|
||||
'retry_date_from' => $narrowedDateFrom,
|
||||
'date_to' => $dateTo,
|
||||
]);
|
||||
|
||||
$dateFrom = $narrowedDateFrom;
|
||||
$strategy = null;
|
||||
}
|
||||
|
||||
$continuationKey = $result['continuation_key'];
|
||||
} while ($continuationKey);
|
||||
}
|
||||
|
||||
if ($saveDailyBalances) {
|
||||
$this->saveDailyBalances($account, $dailyBalances);
|
||||
|
|
@ -76,6 +120,28 @@ class TransactionSyncService
|
|||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next window start strictly narrower (later) than the current one,
|
||||
* stepping down the bounded lookback ladder. Returns null when no ladder
|
||||
* step narrows the current window, so the caller gives up on the account
|
||||
* rather than looping forever. Candidates are always <= date_to, so the
|
||||
* window never inverts. Dates are 'Y-m-d', where string order is date order.
|
||||
*/
|
||||
private function nextNarrowerDateFrom(string $dateFrom, string $dateTo): ?string
|
||||
{
|
||||
$to = Carbon::parse($dateTo);
|
||||
|
||||
foreach (self::WRONG_PERIOD_LOOKBACK_DAYS as $days) {
|
||||
$candidate = $to->copy()->subDays($days)->toDateString();
|
||||
|
||||
if ($candidate > $dateFrom) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a single transaction, skipping duplicates.
|
||||
*
|
||||
|
|
@ -115,6 +181,7 @@ class TransactionSyncService
|
|||
try {
|
||||
$account->transactions()->create([
|
||||
'user_id' => $account->user_id,
|
||||
'space_id' => $account->space_id,
|
||||
'description' => $formatted['description'],
|
||||
'description_iv' => null,
|
||||
'original_description' => $formatted['original_description'],
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@ class WiseTransactionSyncService
|
|||
try {
|
||||
$account->transactions()->create([
|
||||
'user_id' => $account->user_id,
|
||||
'space_id' => $account->space_id,
|
||||
'description' => $parsed['description'],
|
||||
'description_iv' => null,
|
||||
'original_description' => $parsed['description'],
|
||||
|
|
|
|||
|
|
@ -9,6 +9,15 @@ use Illuminate\Support\Str;
|
|||
|
||||
class LoanBalanceGeneratorService
|
||||
{
|
||||
/**
|
||||
* Upsert historical balances in batches of this size. An old loan start
|
||||
* date (which has no lower bound in validation) can produce a very long
|
||||
* monthly series; building and upserting it all at once exhausts the queue
|
||||
* worker's memory in Arr::map/flatten (PHP-LARAVEL-49). Batching bounds
|
||||
* peak memory.
|
||||
*/
|
||||
private const UPSERT_CHUNK_SIZE = 500;
|
||||
|
||||
/**
|
||||
* Generate historical monthly balances from a loan's start date to today
|
||||
* using linear interpolation between the original amount owed and the
|
||||
|
|
@ -73,6 +82,25 @@ class LoanBalanceGeneratorService
|
|||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if (count($rows) >= self::UPSERT_CHUNK_SIZE) {
|
||||
$this->upsertBalances($rows);
|
||||
$rows = [];
|
||||
}
|
||||
}
|
||||
|
||||
$this->upsertBalances($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a batch of balance rows, keyed by (account_id, balance_date).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function upsertBalances(array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
AccountBalance::upsert($rows, ['account_id', 'balance_date'], ['balance', 'updated_at']);
|
||||
|
|
|
|||
|
|
@ -2,18 +2,17 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ManualBalanceAdjuster
|
||||
{
|
||||
/**
|
||||
* Reverse a deleted transaction's effect on its manual account's current balance.
|
||||
* Reverse a deleted transaction's effect on its manual account's balances.
|
||||
*
|
||||
* Adjusts today's balance by the inverse of the transaction amount: an expense
|
||||
* (negative amount) increases the balance, income (positive amount) decreases it.
|
||||
* Connected accounts are skipped because their balances come from bank sync.
|
||||
* Subtracts the transaction amount from its own day and every later
|
||||
* snapshot, mirroring the forward shift applied on creation. Connected
|
||||
* accounts are skipped because their balances come from bank sync.
|
||||
*/
|
||||
public function reverseDeletedTransaction(Transaction $transaction): void
|
||||
{
|
||||
|
|
@ -23,31 +22,20 @@ class ManualBalanceAdjuster
|
|||
return;
|
||||
}
|
||||
|
||||
$today = Carbon::now()->toDateString();
|
||||
|
||||
$currentBalance = $account->balances()
|
||||
->where('balance_date', '<=', $today)
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
AccountBalance::updateOrCreate(
|
||||
[
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $today,
|
||||
],
|
||||
[
|
||||
'balance' => $currentBalance - $transaction->amount,
|
||||
],
|
||||
$this->shiftBalancesFrom(
|
||||
$account,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
-$transaction->amount,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a newly created transaction to its manual account's balance.
|
||||
* Apply a newly created transaction to its manual account's balances.
|
||||
*
|
||||
* Adjusts the balance on the transaction's own date. The base is that day's
|
||||
* balance if one exists, otherwise the closest earlier balance, otherwise
|
||||
* zero (the first transaction on the account). Connected accounts are
|
||||
* skipped because their balances come from bank sync.
|
||||
* Seeds a snapshot on the transaction's own date (from the carried-forward
|
||||
* balance when none exists yet), then shifts that day and every later
|
||||
* snapshot by the transaction amount. Connected accounts are skipped
|
||||
* because their balances come from bank sync.
|
||||
*/
|
||||
public function applyCreatedTransaction(Transaction $transaction): void
|
||||
{
|
||||
|
|
@ -59,19 +47,36 @@ class ManualBalanceAdjuster
|
|||
|
||||
$transactionDate = $transaction->transaction_date->toDateString();
|
||||
|
||||
$baseBalance = $account->balances()
|
||||
->where('balance_date', '<=', $transactionDate)
|
||||
$account->balances()->firstOrCreate(
|
||||
['balance_date' => $transactionDate],
|
||||
['balance' => $this->carriedForwardBalance($account, $transactionDate)],
|
||||
);
|
||||
|
||||
$this->shiftBalancesFrom($account, $transactionDate, $transaction->amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift every balance snapshot on or after the given date by the delta.
|
||||
*
|
||||
* Balances carry forward, so a retroactive change must move the
|
||||
* transaction's own day and every later snapshot (such as today's current
|
||||
* balance) by the same amount to keep the running balance consistent.
|
||||
*/
|
||||
private function shiftBalancesFrom(Account $account, string $fromDate, int $delta): void
|
||||
{
|
||||
$account->balances()
|
||||
->where('balance_date', '>=', $fromDate)
|
||||
->increment('balance', $delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent balance strictly before the given date, or 0 if none.
|
||||
*/
|
||||
private function carriedForwardBalance(Account $account, string $date): int
|
||||
{
|
||||
return $account->balances()
|
||||
->where('balance_date', '<', $date)
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
AccountBalance::updateOrCreate(
|
||||
[
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $transactionDate,
|
||||
],
|
||||
[
|
||||
'balance' => $baseBalance + $transaction->amount,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ use Illuminate\Support\Str;
|
|||
|
||||
class RealEstateBalanceGeneratorService
|
||||
{
|
||||
/**
|
||||
* Upsert historical balances in batches of this size. An old purchase date
|
||||
* (which has no lower bound in validation) can produce a very long monthly
|
||||
* series; building and upserting it all at once exhausts the queue worker's
|
||||
* memory in Arr::map/flatten (PHP-LARAVEL-49). Batching bounds peak memory.
|
||||
*/
|
||||
private const UPSERT_CHUNK_SIZE = 500;
|
||||
|
||||
/**
|
||||
* Generate historical monthly balances from purchase date to today
|
||||
* using linear interpolation between purchase price and current value.
|
||||
|
|
@ -73,6 +81,25 @@ class RealEstateBalanceGeneratorService
|
|||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
if (count($rows) >= self::UPSERT_CHUNK_SIZE) {
|
||||
$this->upsertBalances($rows);
|
||||
$rows = [];
|
||||
}
|
||||
}
|
||||
|
||||
$this->upsertBalances($rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a batch of balance rows, keyed by (account_id, balance_date).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function upsertBalances(array $rows): void
|
||||
{
|
||||
if ($rows === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
AccountBalance::upsert($rows, ['account_id', 'balance_date'], ['balance', 'updated_at']);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* One arm of a binomial experiment: a count of successes out of trials, with a
|
||||
* label. Bundles the (successes, trials) pair that the significance methods
|
||||
* would otherwise pass around as loose integers.
|
||||
*/
|
||||
final readonly class BinomialProportion
|
||||
{
|
||||
public function __construct(
|
||||
public string $label,
|
||||
public int $successes,
|
||||
public int $trials,
|
||||
) {}
|
||||
|
||||
public function rate(): float
|
||||
{
|
||||
return $this->trials > 0 ? (float) $this->successes / $this->trials : 0.0;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,9 +6,9 @@ use App\Features\SubscriptionExperiment;
|
|||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Cashier\Subscription;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class ExperimentFunnelCollector
|
||||
{
|
||||
|
|
@ -20,41 +20,62 @@ class ExperimentFunnelCollector
|
|||
|
||||
/**
|
||||
* Per-variant funnel for the trial/pricing experiment. Users are attributed
|
||||
* by the variant Pennant resolved for them — the same value the runtime
|
||||
* served at checkout/paywall — so the report can't drift from what users
|
||||
* actually experienced (including any QA override or a legacy bucket that
|
||||
* predates the experiment). "Net active" is a live, non-refunded
|
||||
* subscription — an exact, heuristic-free metric that is comparable across
|
||||
* variants once each cohort clears its own decision window.
|
||||
* by SubscriptionExperiment::bucket() — the deterministic crc32 split that is
|
||||
* the single source of truth for assignment — over the in-window signups the
|
||||
* query selects, so it matches the variant each user was served without being
|
||||
* perturbed by the force_variant rollout hook (which pins every user to the
|
||||
* winner once decided) or by Pennant store drift. "Net active" is a live,
|
||||
* non-refunded subscription — an exact, heuristic-free metric that is
|
||||
* comparable across variants once each cohort clears its own decision window.
|
||||
*
|
||||
* The funnel is assigned → activated → carded (subscribed) → net-paying:
|
||||
* "activated" = the user connected a bank or enabled AI, i.e. triggered the
|
||||
* paid infrastructure that costs us money, whether or not they ever paid. The
|
||||
* gap activated → carded (completed Checkout with a card) is where a user
|
||||
* connects a bank and walks away without paying — the exact leak the flat
|
||||
* per-connection cost estimate quantifies.
|
||||
*
|
||||
* Revenue is the monthly-recurring run-rate (MRR) of the mature, net-active
|
||||
* subscriptions, with yearly plans normalised to a monthly equivalent, plus
|
||||
* ARPU = MRR ÷ assigned (mature) — the per-signup revenue each variant earns.
|
||||
* This is run-rate, not realised cash, so it does not credit pay_now's yearly
|
||||
* upfront payment any differently from a monthly one. Full LTV needs a churn
|
||||
* rate the experiment is too young to have; ARPU is the proxy until then.
|
||||
* ARPU = MRR ÷ assigned (mature). Cost is a flat estimate: connections of the
|
||||
* mature cohort × `$costPerConnectionCents`; "wasted" cost is the same for
|
||||
* mature users who did not convert (money burned). Contribution margin =
|
||||
* MRR − cost, the decision metric once every variant has mature volume. This
|
||||
* is run-rate, not realised cash, so it does not credit pay_now's yearly
|
||||
* upfront payment any differently from a monthly one.
|
||||
*
|
||||
* @param int $costPerConnectionCents flat estimated cost per bank connection
|
||||
* @return array{
|
||||
* startedAt: ?CarbonImmutable,
|
||||
* currency: string,
|
||||
* revenueAvailable: bool,
|
||||
* costPerConnectionCents: int,
|
||||
* variants: array<string, array{
|
||||
* assigned: int,
|
||||
* activated: int,
|
||||
* subscribed: int,
|
||||
* trialing: int,
|
||||
* trialingCanceling: int,
|
||||
* active: int,
|
||||
* canceled: int,
|
||||
* pastDue: int,
|
||||
* refunded: int,
|
||||
* assignedMature: int,
|
||||
* activatedMature: int,
|
||||
* convertedMature: int,
|
||||
* activeMature: int,
|
||||
* conversionRate: ?float,
|
||||
* netActiveRate: ?float,
|
||||
* activationToPaidRate: ?float,
|
||||
* mrrCents: int,
|
||||
* arpuCents: ?int,
|
||||
* costCents: int,
|
||||
* wastedCostCents: int,
|
||||
* contributionMarginCents: int,
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public function collect(): array
|
||||
public function collect(int $costPerConnectionCents = 40): array
|
||||
{
|
||||
$startedValue = config('subscriptions.experiment.started_at');
|
||||
$startedAt = $startedValue !== null ? CarbonImmutable::parse($startedValue) : null;
|
||||
|
|
@ -67,24 +88,40 @@ class ExperimentFunnelCollector
|
|||
];
|
||||
|
||||
if ($startedAt === null) {
|
||||
return ['startedAt' => null, 'currency' => $currency, 'revenueAvailable' => false, 'variants' => $variants];
|
||||
return ['startedAt' => null, 'currency' => $currency, 'revenueAvailable' => false, 'costPerConnectionCents' => $costPerConnectionCents, 'variants' => $variants];
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$excluded = (array) config('ai_suggestions.report.excluded_emails', []);
|
||||
$windows = $this->decisionWindows();
|
||||
$monthlyEquiv = $this->monthlyEquivByPriceId();
|
||||
$missingPrices = [];
|
||||
|
||||
User::query()
|
||||
// Soft-deleted accounts still count: they were assigned a variant and
|
||||
// their bank connections incurred real cost, and deleting the account
|
||||
// is itself an experiment outcome (the strongest "connect and leave").
|
||||
->withTrashed()
|
||||
->where('users.created_at', '>=', $startedAt)
|
||||
->when($excluded !== [], fn ($query) => $query->whereNotIn('email', $excluded))
|
||||
->with(['subscriptions' => fn ($query) => $query->where('type', 'default')])
|
||||
->select(['id', 'created_at'])
|
||||
->chunkById(500, function ($users) use (&$variants, $windows, $now, $monthlyEquiv): void {
|
||||
Feature::for($users)->load([SubscriptionExperiment::class]);
|
||||
|
||||
->withCount([
|
||||
// Cost is incurred by every connection ever opened, so count
|
||||
// soft-deleted (revoked) ones too — they still cost us money.
|
||||
'bankingConnections as connection_count' => fn ($query) => $query->withTrashed(),
|
||||
'aiConsents as ai_consent_count',
|
||||
])
|
||||
->chunkById(500, function ($users) use (&$variants, &$missingPrices, $windows, $now, $monthlyEquiv, $costPerConnectionCents): void {
|
||||
foreach ($users as $user) {
|
||||
$variant = Feature::for($user)->value(SubscriptionExperiment::class);
|
||||
// Attribute by the deterministic bucket (the single source of
|
||||
// truth in SubscriptionExperiment), not the resolved Pennant
|
||||
// value: the latter is short-circuited by the force_variant
|
||||
// rollout hook, which would collapse every user onto one
|
||||
// variant once a winner is pinned. Every queried user is
|
||||
// in-window, so bucket() equals the variant they were served,
|
||||
// and reading it avoids writing Pennant rows as a side effect.
|
||||
$variant = SubscriptionExperiment::bucket((string) $user->id);
|
||||
|
||||
if (! isset($variants[$variant])) {
|
||||
continue;
|
||||
|
|
@ -94,14 +131,37 @@ class ExperimentFunnelCollector
|
|||
|
||||
$row['assigned']++;
|
||||
|
||||
$connections = (int) ($user->connection_count ?? 0);
|
||||
$activated = $connections > 0 || (int) ($user->ai_consent_count ?? 0) > 0;
|
||||
|
||||
if ($activated) {
|
||||
$row['activated']++;
|
||||
}
|
||||
|
||||
/** @var Subscription|null $subscription */
|
||||
$subscription = $user->subscriptions->sortByDesc('created_at')->first();
|
||||
$status = $subscription?->stripe_status;
|
||||
$netActive = $status === 'active' && $subscription->refunded_at === null;
|
||||
|
||||
// "Converted" is time-invariant: the user was ever charged and
|
||||
// not refunded — currently active, or churned after the trial.
|
||||
// Unlike $netActive (a live snapshot), it does not shrink as an
|
||||
// older cohort has more time to cancel, so it is comparable
|
||||
// across variants that matured at different times. Excludes
|
||||
// trial-only cancels (ended on/before the trial → never charged).
|
||||
$converted = $subscription !== null
|
||||
&& $subscription->refunded_at === null
|
||||
&& $status !== 'trialing'
|
||||
&& (
|
||||
$subscription->trial_ends_at === null
|
||||
|| $subscription->ends_at === null
|
||||
|| $subscription->ends_at->greaterThan($subscription->trial_ends_at)
|
||||
);
|
||||
|
||||
if ($subscription !== null) {
|
||||
$row['subscribed']++;
|
||||
$row['trialing'] += $status === 'trialing' ? 1 : 0;
|
||||
$row['trialingCanceling'] += ($status === 'trialing' && $subscription->ends_at !== null) ? 1 : 0;
|
||||
$row['active'] += $status === 'active' ? 1 : 0;
|
||||
$row['canceled'] += $status === 'canceled' ? 1 : 0;
|
||||
$row['pastDue'] += $status === 'past_due' ? 1 : 0;
|
||||
|
|
@ -115,9 +175,33 @@ class ExperimentFunnelCollector
|
|||
if ($mature) {
|
||||
$row['assignedMature']++;
|
||||
|
||||
$connectionCostCents = $connections * $costPerConnectionCents;
|
||||
$row['costCents'] += $connectionCostCents;
|
||||
|
||||
if ($activated) {
|
||||
$row['activatedMature']++;
|
||||
}
|
||||
|
||||
if ($converted) {
|
||||
$row['convertedMature']++;
|
||||
}
|
||||
|
||||
if ($netActive) {
|
||||
$row['activeMature']++;
|
||||
$row['mrrCents'] += (int) ($monthlyEquiv[$subscription->stripe_price] ?? 0);
|
||||
$priceId = (string) $subscription->stripe_price;
|
||||
|
||||
if ($monthlyEquiv !== [] && ! isset($monthlyEquiv[$priceId])) {
|
||||
$missingPrices[$priceId] = true;
|
||||
}
|
||||
|
||||
$row['mrrCents'] += (int) ($monthlyEquiv[$priceId] ?? 0);
|
||||
} elseif ($subscription === null || $subscription->refunded_at !== null) {
|
||||
// Burn = connections of matured users who never earned
|
||||
// net revenue: connected a bank but never carded, or
|
||||
// paid and got refunded. A user who paid and later
|
||||
// churned (canceled, not refunded) did convert, so
|
||||
// their connection cost is not burn.
|
||||
$row['wastedCostCents'] += $connectionCostCents;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,48 +209,76 @@ class ExperimentFunnelCollector
|
|||
}
|
||||
});
|
||||
|
||||
if ($missingPrices !== []) {
|
||||
Log::warning('Experiment funnel: net-active subscriptions on prices absent from the monthly-equivalent map — their MRR is undercounted as 0.', [
|
||||
'price_ids' => array_keys($missingPrices),
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($variants as $key => $row) {
|
||||
$variants[$key]['conversionRate'] = $row['assignedMature'] > 0
|
||||
? (float) $row['convertedMature'] / $row['assignedMature']
|
||||
: null;
|
||||
$variants[$key]['netActiveRate'] = $row['assignedMature'] > 0
|
||||
? $row['activeMature'] / $row['assignedMature']
|
||||
? (float) $row['activeMature'] / $row['assignedMature']
|
||||
: null;
|
||||
$variants[$key]['activationToPaidRate'] = $row['activatedMature'] > 0
|
||||
? (float) $row['activeMature'] / $row['activatedMature']
|
||||
: null;
|
||||
$variants[$key]['arpuCents'] = $row['assignedMature'] > 0
|
||||
? (int) round($row['mrrCents'] / $row['assignedMature'])
|
||||
: null;
|
||||
$variants[$key]['contributionMarginCents'] = $row['mrrCents'] - $row['costCents'];
|
||||
}
|
||||
|
||||
return [
|
||||
'startedAt' => $startedAt,
|
||||
'currency' => $currency,
|
||||
'revenueAvailable' => $monthlyEquiv !== [],
|
||||
'costPerConnectionCents' => $costPerConnectionCents,
|
||||
'variants' => $variants,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{assigned: int, subscribed: int, trialing: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activeMature: int, netActiveRate: ?float, mrrCents: int, arpuCents: ?int}
|
||||
* @return array{assigned: int, activated: int, subscribed: int, trialing: int, trialingCanceling: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activatedMature: int, convertedMature: int, activeMature: int, conversionRate: ?float, netActiveRate: ?float, activationToPaidRate: ?float, mrrCents: int, arpuCents: ?int, costCents: int, wastedCostCents: int, contributionMarginCents: int}
|
||||
*/
|
||||
private function emptyRow(): array
|
||||
{
|
||||
return [
|
||||
'assigned' => 0,
|
||||
'activated' => 0,
|
||||
'subscribed' => 0,
|
||||
'trialing' => 0,
|
||||
'trialingCanceling' => 0,
|
||||
'active' => 0,
|
||||
'canceled' => 0,
|
||||
'pastDue' => 0,
|
||||
'refunded' => 0,
|
||||
'assignedMature' => 0,
|
||||
'activatedMature' => 0,
|
||||
'convertedMature' => 0,
|
||||
'activeMature' => 0,
|
||||
'conversionRate' => null,
|
||||
'netActiveRate' => null,
|
||||
'activationToPaidRate' => null,
|
||||
'mrrCents' => 0,
|
||||
'arpuCents' => null,
|
||||
'costCents' => 0,
|
||||
'wastedCostCents' => 0,
|
||||
'contributionMarginCents' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Monthly-equivalent amount (in cents) for each plan price id, from Stripe.
|
||||
* Yearly prices are divided by 12. Cached for an hour; returns [] (revenue
|
||||
* unavailable) if Stripe can't be reached, without caching the failure.
|
||||
* Yearly prices are divided by 12. Fetched by product so that archived,
|
||||
* rotated price ids (Stripe mints a new id and transfers the lookup key on
|
||||
* any amount change) still resolve — otherwise subscriptions on an old id
|
||||
* would silently contribute 0 to MRR. Falls back to the current lookup keys
|
||||
* when no product is configured. Foreign-currency and one-off prices are
|
||||
* skipped. Cached for an hour; returns [] (revenue unavailable) if Stripe
|
||||
* can't be reached, without caching the failure.
|
||||
*
|
||||
* @return array<string, int>
|
||||
*/
|
||||
|
|
@ -178,26 +290,40 @@ class ExperimentFunnelCollector
|
|||
return Cache::get($key);
|
||||
}
|
||||
|
||||
$productId = config('subscriptions.products.pro');
|
||||
$lookups = array_values(array_filter([
|
||||
config('subscriptions.plans.monthly.stripe_lookup_key'),
|
||||
config('subscriptions.plans.yearly.stripe_lookup_key'),
|
||||
]));
|
||||
|
||||
if ($lookups === []) {
|
||||
if ($productId === null && $lookups === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$params = $productId !== null
|
||||
? ['product' => $productId, 'limit' => 100]
|
||||
: ['lookup_keys' => $lookups, 'limit' => 10];
|
||||
|
||||
try {
|
||||
$prices = Cashier::stripe()->prices->all(['lookup_keys' => $lookups, 'limit' => 10]);
|
||||
$prices = Cashier::stripe()->prices->all($params);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$currency = strtolower((string) config('cashier.currency', 'eur'));
|
||||
$map = [];
|
||||
foreach ($prices->data as $price) {
|
||||
if ($price->recurring === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower((string) ($price->currency ?? $currency)) !== $currency) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = (int) ($price->unit_amount ?? 0);
|
||||
$map[$price->id] = ($price->recurring->interval ?? 'month') === 'year'
|
||||
? intdiv($amount, 12)
|
||||
? (int) round($amount / 12)
|
||||
: $amount;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stats;
|
||||
|
||||
/**
|
||||
* Frequentist inference on binomial proportions for the experiment funnel:
|
||||
* per-arm Wilson intervals, a Newcombe interval for the difference of two
|
||||
* proportions, and a Fisher exact test for the leader-vs-runner-up comparison.
|
||||
*
|
||||
* Fisher (exact at any sample size) is the decision test because the report's
|
||||
* conversion counts are too small for the two-proportion z normal approximation
|
||||
* to be valid — its expected cell counts fall well below the np>=5 rule.
|
||||
*/
|
||||
final class ProportionSignificance
|
||||
{
|
||||
/** Two-sided 95% standard-normal quantile. */
|
||||
private const Z_95 = 1.96;
|
||||
|
||||
/** @var list<float> memoised log-factorials, index i = log(i!) */
|
||||
private array $logFactorials = [0.0, 0.0];
|
||||
|
||||
/**
|
||||
* Compare the two leading arms: Newcombe difference interval, Fisher exact
|
||||
* p-value, and a family-wise (Bonferroni) corrected significance flag. `z`
|
||||
* and `minExpectedCount` are returned for the small-sample caveat only.
|
||||
*
|
||||
* @return array{alpha: float, diffLow: float, diffHigh: float, fisherP: float, significant: bool, minExpectedCount: float, z: float}
|
||||
*/
|
||||
public function compare(BinomialProportion $leader, BinomialProportion $runnerUp, float $familyAlpha = 0.05, int $comparisons = 3): array
|
||||
{
|
||||
$alpha = $familyAlpha / $comparisons;
|
||||
[$diffLow, $diffHigh] = $this->newcombeDiffInterval($leader, $runnerUp);
|
||||
$fisherP = $this->fisherExactTwoSided(
|
||||
$leader->successes, $leader->trials - $leader->successes,
|
||||
$runnerUp->successes, $runnerUp->trials - $runnerUp->successes,
|
||||
);
|
||||
|
||||
$pooled = ($leader->successes + $runnerUp->successes) / ($leader->trials + $runnerUp->trials);
|
||||
$minExpectedCount = min($leader->trials, $runnerUp->trials) * min($pooled, 1 - $pooled);
|
||||
|
||||
return [
|
||||
'alpha' => $alpha,
|
||||
'diffLow' => $diffLow,
|
||||
'diffHigh' => $diffHigh,
|
||||
'fisherP' => $fisherP,
|
||||
'significant' => $fisherP < $alpha,
|
||||
'minExpectedCount' => $minExpectedCount,
|
||||
'z' => $this->twoProportionZ($leader, $runnerUp),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wilson score interval for a binomial proportion — accurate for small n
|
||||
* and near 0/1, where the normal approximation misbehaves.
|
||||
*
|
||||
* @return array{0: float, 1: float} lower and upper bound, clamped to [0, 1]
|
||||
*/
|
||||
public function wilsonInterval(int $successes, int $trials, float $z = self::Z_95): array
|
||||
{
|
||||
$p = $successes / $trials;
|
||||
$z2 = $z * $z;
|
||||
$denom = 1 + $z2 / $trials;
|
||||
$center = ($p + $z2 / (2 * $trials)) / $denom;
|
||||
$margin = ($z / $denom) * sqrt($p * (1 - $p) / $trials + $z2 / (4 * $trials * $trials));
|
||||
|
||||
return [max(0.0, $center - $margin), min(1.0, $center + $margin)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Newcombe (Wilson-based) 95% interval for the difference pA − pB. The
|
||||
* correct object for "is A better than B": overlapping marginal intervals do
|
||||
* NOT imply the difference includes 0.
|
||||
*
|
||||
* @return array{0: float, 1: float}
|
||||
*/
|
||||
public function newcombeDiffInterval(BinomialProportion $a, BinomialProportion $b, float $z = self::Z_95): array
|
||||
{
|
||||
$pA = $a->rate();
|
||||
$pB = $b->rate();
|
||||
[$lA, $uA] = $this->wilsonInterval($a->successes, $a->trials, $z);
|
||||
[$lB, $uB] = $this->wilsonInterval($b->successes, $b->trials, $z);
|
||||
|
||||
$lower = ($pA - $pB) - sqrt(($pA - $lA) ** 2 + ($uB - $pB) ** 2);
|
||||
$upper = ($pA - $pB) + sqrt(($uA - $pA) ** 2 + ($pB - $lB) ** 2);
|
||||
|
||||
return [$lower, $upper];
|
||||
}
|
||||
|
||||
/** Pooled two-proportion z statistic — descriptive only, not the decision test. */
|
||||
public function twoProportionZ(BinomialProportion $a, BinomialProportion $b): float
|
||||
{
|
||||
$pooled = ($a->successes + $b->successes) / ($a->trials + $b->trials);
|
||||
$se = sqrt($pooled * (1 - $pooled) * (1 / $a->trials + 1 / $b->trials));
|
||||
|
||||
return $se > 0.0 ? ($a->rate() - $b->rate()) / $se : 0.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-sided Fisher exact p-value for the 2x2 table [[a, b], [c, d]]
|
||||
* (a/c = successes, b/d = failures). Sums the hypergeometric probabilities
|
||||
* of every same-margin table no more likely than the observed one. Exact at
|
||||
* any sample size — no normal approximation.
|
||||
*/
|
||||
public function fisherExactTwoSided(int $a, int $b, int $c, int $d): float
|
||||
{
|
||||
$rowA = $a + $b;
|
||||
$rowB = $c + $d;
|
||||
$col = $a + $c;
|
||||
$total = $rowA + $rowB;
|
||||
|
||||
if ($rowA === 0 || $rowB === 0 || $col === 0 || $col === $total) {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
$logProbObserved = $this->hypergeometricLogProb($a, $rowA, $rowB, $col);
|
||||
$p = 0.0;
|
||||
for ($x = max(0, $col - $rowB); $x <= min($col, $rowA); $x++) {
|
||||
$logProb = $this->hypergeometricLogProb($x, $rowA, $rowB, $col);
|
||||
if ($logProb <= $logProbObserved + 1e-7) {
|
||||
$p += exp($logProb);
|
||||
}
|
||||
}
|
||||
|
||||
return min(1.0, $p);
|
||||
}
|
||||
|
||||
private function hypergeometricLogProb(int $x, int $rowA, int $rowB, int $col): float
|
||||
{
|
||||
return $this->logChoose($rowA, $x) + $this->logChoose($rowB, $col - $x) - $this->logChoose($rowA + $rowB, $col);
|
||||
}
|
||||
|
||||
private function logChoose(int $n, int $k): float
|
||||
{
|
||||
if ($k < 0 || $k > $n) {
|
||||
return -INF;
|
||||
}
|
||||
|
||||
return $this->logFactorial($n) - $this->logFactorial($k) - $this->logFactorial($n - $k);
|
||||
}
|
||||
|
||||
private function logFactorial(int $n): float
|
||||
{
|
||||
for ($i = count($this->logFactorials); $i <= $n; $i++) {
|
||||
$this->logFactorials[$i] = $this->logFactorials[$i - 1] + log($i);
|
||||
}
|
||||
|
||||
return $this->logFactorials[$n];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Formats a minor-unit amount (cents) with its currency symbol, e.g. "€3.99".
|
||||
*
|
||||
* Centralizes the money formatting that was duplicated across the stats report
|
||||
* commands and the Discord Stripe listener. Currencies without a known symbol
|
||||
* fall back to the uppercased currency code plus a trailing space ("CHF 3.99").
|
||||
*/
|
||||
final class Money
|
||||
{
|
||||
public static function format(int $cents, string $currency): string
|
||||
{
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'usd' => '$',
|
||||
'jpy' => '¥',
|
||||
'brl' => 'R$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($cents / 100, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Queue\MaxAttemptsExceededException;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
|
||||
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
|
||||
use Sentry\Laravel\Integration;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
|
|
@ -54,6 +56,8 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
'subscribed' => EnsureUserIsSubscribed::class,
|
||||
'onboarded' => EnsureOnboardingComplete::class,
|
||||
'block-demo' => BlockDemoAccountActions::class,
|
||||
'abilities' => CheckAbilities::class,
|
||||
'ability' => CheckForAnyAbility::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
|
|
|
|||
10
bun.lock
10
bun.lock
|
|
@ -51,7 +51,7 @@
|
|||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"recharts": "^3.9.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
|
@ -1248,7 +1248,7 @@
|
|||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
"immer": ["immer@11.1.11", "", {}, "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
|
|
@ -1766,7 +1766,7 @@
|
|||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"recharts": ["recharts@3.7.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="],
|
||||
"recharts": ["recharts@3.9.2", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^11.1.8", "react-redux": "8.x.x || 9.x.x", "reselect": "5.2.0", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw=="],
|
||||
|
||||
"redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
|
||||
|
||||
|
|
@ -1792,7 +1792,7 @@
|
|||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
"reselect": ["reselect@5.2.0", "", {}, "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw=="],
|
||||
|
||||
"resolve": ["resolve@2.0.0-next.5", "", { "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA=="],
|
||||
|
||||
|
|
@ -2224,6 +2224,8 @@
|
|||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.0.1", "", {}, "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA=="],
|
||||
|
||||
"@reduxjs/toolkit/reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||
|
||||
"@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="],
|
||||
|
||||
"@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@
|
|||
"laravel/cashier": "^16.1",
|
||||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/mcp": "^0.6.7",
|
||||
"laravel/passport": "^13.0",
|
||||
"laravel/pennant": "^1.18",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.9",
|
||||
"resend/resend-php": "^1.1",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -42,6 +42,11 @@ return [
|
|||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'driver' => 'passport',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -164,5 +164,23 @@ return [
|
|||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'SEK',
|
||||
'name' => 'Swedish Krona',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'GTQ',
|
||||
'name' => 'Guatemalan Quetzal',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
[
|
||||
'code' => 'HKD',
|
||||
'name' => 'Hong Kong Dollar',
|
||||
'allows_primary' => true,
|
||||
'allows_account' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redirect Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These domains are the domains that OAuth clients are permitted to use
|
||||
| for redirect URIs. Each domain should be specified with its scheme
|
||||
| and host. Domains not in this list will raise validation errors.
|
||||
|
|
||||
| An "*" may be used to allow all domains.
|
||||
|
|
||||
*/
|
||||
|
||||
'redirect_domains' => [
|
||||
// Anthropic's hosted callback for Claude Desktop / Claude web connectors.
|
||||
'https://claude.ai',
|
||||
// OpenAI's hosted callback for ChatGPT connectors.
|
||||
'https://chatgpt.com',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed Custom Schemes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Native desktop OAuth clients like Cursor and VS Code use private-use URI
|
||||
| schemes (RFC 8252) for redirect callbacks instead of standard schemes
|
||||
| like HTTPS. Here, you may list which custom schemes you will allow.
|
||||
|
|
||||
*/
|
||||
|
||||
'custom_schemes' => [
|
||||
// 'claude',
|
||||
// 'cursor',
|
||||
// 'vscode',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authorization Server
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the OAuth authorization server issuer identifier
|
||||
| per RFC 8414. This value appears in your protected resource and auth
|
||||
| server metadata endpoints. When null, this defaults to `url('/')`.
|
||||
|
|
||||
*/
|
||||
|
||||
'authorization_server' => null,
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Passport will use when
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
'middleware' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Keys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Passport uses encryption keys while generating secure access tokens for
|
||||
| your application. By default, the keys are stored as local files but
|
||||
| can be set via environment variables when that is more convenient.
|
||||
|
|
||||
*/
|
||||
|
||||
'private_key' => env('PASSPORT_PRIVATE_KEY'),
|
||||
|
||||
'public_key' => env('PASSPORT_PUBLIC_KEY'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Passport's models will utilize your application's default
|
||||
| database connection. If you wish to use a different connection you
|
||||
| may specify the configured name of the database connection here.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('PASSPORT_CONNECTION'),
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
|
||||
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort(),
|
||||
// Sanctum::currentRequestHost(),
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => AuthenticateSession::class,
|
||||
'encrypt_cookies' => EncryptCookies::class,
|
||||
'validate_csrf_token' => ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\ImportConfigType;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountImportConfig;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<AccountImportConfig>
|
||||
*/
|
||||
class AccountImportConfigFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'account_id' => Account::factory(),
|
||||
'type' => ImportConfigType::Transaction,
|
||||
'config' => [
|
||||
'columnMapping' => [
|
||||
'transaction_date' => 'Date',
|
||||
'description' => 'Description',
|
||||
'amount' => 'Amount',
|
||||
'balance' => null,
|
||||
'creditor_name' => null,
|
||||
'debtor_name' => null,
|
||||
],
|
||||
'dateFormat' => 'YYYY-MM-DD',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Space>
|
||||
*/
|
||||
class SpaceFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'owner_id' => User::factory(),
|
||||
'name' => fake()->company(),
|
||||
'personal' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function personal(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'personal' => true,
|
||||
'name' => 'Personal',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('spaces', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('owner_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->boolean('personal')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('owner_id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('spaces');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue