Conversation
Successful builds from the default branch now trigger an automated staging deployment instead of requiring a manual rollout.
The vendored Swagger UI bundle is replaced by a single page that loads the viewer from a CDN, keeping third party build artifacts out of the repository. The docs page and the welcome link to it are now only exposed outside production, matching our other services.
AI chat answers previously relied on the language model choosing to scope its SQL to the asking user, so a suitably phrased question could return other users' financial data. Queries against user-owned tables now require the tenant filter and it is injected when missing, failing closed. Only the explicitly listed tables can be queried at all. Requires SmartQL 0.1.1 or later, which is what the latest image provides. Closes #244
The trusted role is attached to SmartQL requests server-side from the authenticated user's role, never from user input, so admins bypass tenant scoping while regular users stay fully scoped.
Plugin engine output is now filtered to warnings and above by default and can be routed to its own channel, removing the per-request debug and missing-directory noise from production logs. Discovery can also be compiled to a cache file on deploy.
The statistics endpoint feeds dashboard and reports figures that the client now trusts and caches more heavily, so its computed values need to be pinned down by tests. Adds checks that the endpoint returns correct previous-period comparisons, largest transactions, activity metrics, party and category breakdowns, expense distribution by wallet, monthly cash flow figures, and multi-currency conversion to the user's default currency. Also covers transfer exclusion, single-transaction and empty-period edge cases, response caching, and cache invalidation after a transaction changes.
The shared coverage action was referenced by a branch that no longer exists, so the test job aborted while resolving actions before any test ran. Point it at the maintained branch so the suite runs again.
The statistics endpoint computed and returned the entire payload on every request, so stats-heavy pages waited for all aggregations before showing anything. A request can now ask for a single section, so cheap parts such as the headline totals return quickly while heavier breakdowns and charts are fetched separately. This lets the dashboard fill in progressively instead of blocking on one large response. A request with no section returns the full payload exactly as before. An unknown section is rejected.
Compute each stats section through a single dispatch rather than a chain of conditionals. Per-section behaviour and the full payload are unchanged; this keeps the aggregator within its complexity budget now that it supports section-scoped requests.
List the section query parameter and its allowed values on the statistics endpoint so the published API documentation reflects section-scoped requests.
Merges never produced a per-commit image: the build was chained to a check that does not exist, and even when reached it resolved to the default branch tip rather than the merged commit. Now every merge to the default branch builds and publishes an image tagged with that commit, then rolls that exact image out to staging and runs migrations and cache optimization. A published release builds and publishes the latest image and its version tag for production to pull, matching the local production build.
Publishing the image and deploying it are distinct concerns, so they now live in separate workflows. One builds and publishes the image on merge and on release. The other deploys the published commit image to staging after a successful build, and runs migrations and cache optimization. Releases only publish and do not deploy to staging.
Per-commit images are now tagged and deployed with a short commit hash instead of the full-length one. The staging deploy also referenced a FlatRun setup action version that does not exist, so the deploy failed while resolving actions before any step ran; it now points at the action's published branch.
The assistant can read a user's finances and act on them through tools, but every change (recording a transaction, creating a wallet, category or party, recategorising) is first proposed for the user to confirm or reject before anything is saved, and the action is audited. Answers are composed from rendered widgets (headline numbers, tables, charts and prose) instead of raw text, and recent conversation context is passed to the assistant so follow-up turns make sense.
A document attached in chat (statement, receipt, invoice) is analysed in the background into suggested transactions the user reviews and confirms; a receipt can become a single proposed transaction or be attached to an existing one. The chat shows the import's live status and links to its review. The uploaded file's classification is kept in a metadata field on the file.
Rich multi-section reports render in a side canvas and can be exported. Asking to change a previous report updates it in place instead of starting a new blank one.
Manual transaction creation now uses the same owner-scoped write path the assistant uses, so validation and sync bookkeeping stay identical for both.
SmartQL reads the provider's native API key from the environment instead of a generic variable.
Proposed agent actions now record their owner the same way chat sessions and budgets do, so a proposal can later belong to a shared workspace rather than only an individual user. Behavior is unchanged today, since the owner is always the requesting user.
The assistant can now record a transaction against a party, move money between two wallets, and ask the user a clarifying question with options instead of guessing. Reports can include more section types (highlighted callouts, timelines, progress toward targets) and can chart any data the assistant has queried, not only a fixed set of analytics. An ongoing conversation now keeps its context when a message is routed, so answering a follow-up (such as which wallet to use) continues the task in progress rather than being treated as a fresh lookup. Editing a proposed action before confirming updates its summary to match what was actually saved. Party statistics no longer fail when an exchange rate is unavailable; the affected amounts are excluded and the result is flagged as partial. The API documentation now loads over the correct scheme behind a TLS-terminating proxy, staging deploys run the background worker on the same image as the app, and long-running chat jobs are no longer retried before they finish.
The published API documentation always showed version 1.0.0, a stale placeholder, regardless of the real release. The version now comes from the project's single version field and is written into the generated spec during generation, so the docs reflect the actual current version and stay correct as it changes.
… users
- Uses $request->user('sanctum') to resolve user from bearer token directly
- Authenticated users: 120/min per user (key: u:{userId})
- Guests: 30/min per IP (key: ip:{ip})
- Fixes spurious 429s for users behind shared IPs (carrier-grade NAT, offices)
- Mobile sync bursts no longer trigger rate limits
Ref: #247
Guard the statistics endpoint so party figures cannot silently drift from each transaction's own currency. - One test confirms a party's spending is summed from each transaction converted out of its own wallet currency, not a single assumed currency. - Another confirms the response is flagged partial, listing the affected currencies, when no exchange rate is available to convert an amount.
Introduces a permissive gating point so paid features, limits and AI usage allowances can be enforced by an external plugin, while the open core grants everything by default and stays free to self-host. Gating is keyed on the resource owner rather than the user directly, so a shared owner (a future workspace or couple) is gated as one unit. AI chat now stops a turn when its usage allowance is exhausted and records the tokens it consumes, and wallet and category creation respect configured caps. With no plugin overriding the default, behaviour is unchanged and unlimited.
Adds a reusable pipeline that produces a private hosted image by stacking private paid plugins onto the public base image. Each plugin is fetched and installed, then enabled and cached so its features are available out of the box, while the public base and source remain free of any paid code.
Document processors can now declare a selection priority, so an installed plugin can take precedence over the configured engine for a file type instead of losing to whatever was registered first. Equal priority keeps registration order, so existing behaviour is unchanged.
The branch had pulled in an unrelated client package and rewritten the whole lockfile in one sweep. Pin only the MCP server package and its schema dependency, leaving the rest of the resolved set untouched.
Replace the hand-rolled SSE transport, controller, and route wiring with the MCP package's own Streamable-HTTP transport, registered behind the Sanctum guard and rate limit. The server now declares its capabilities directly and folds in any plugin-provided tools when it starts. The endpoint is off by default in production.
Add read tools for wallets, transactions, categories, parties, and analytics, plus write tools to create a wallet and record a transaction. Writes are gated by the connecting user's permissions and reuse the same validation and creation paths as the REST API, so an AI client cannot bypass ownership or limits.
Add endpoints to create, list, and revoke the personal access tokens a user needs to connect an external AI client to the MCP server. Tokens are tagged so they list separately from session tokens, and the raw token is returned only once on creation.
Drive the tools and the token endpoints through their real boundaries, gate writes on the connecting user so they work without extra setup while an operator can still lock them down, and rewrite the integration guide to match the actual endpoint, tools, and auth.
Creating a wallet, category, or party is usually a step toward a larger request. Confirming one now resumes the assistant against the original message so it carries on on its own (for example proposing the transaction that needed the new wallet), instead of stopping and waiting for the user to ask again. Closes #275
Every chat turn now records its token usage against the requesting user through a dedicated metrics package, so usage and cost can be reported per account. The package's own read routes stay off; usage is available to the app directly. Closes #284
Tools are exposed under plain names (list-wallets, record-transaction) instead of a class-derived suffix, and list responses return only the intended fields rather than leaking model sync state and computed appends. Also drops config and env keys that were never read.
Name the token route parameter in full and wrap the analytics tool description so the static-analysis and style checks pass.
Code Review SummaryThis PR promotes the v2.0.0-beta.1 Ailanthus release, introducing a sophisticated AI assistant architecture, Model Context Protocol server, and bank statement import improvements. It establishes a pluggable entitlements layer and a metrics system for admins. 🚀 Key Improvements
💡 Minor Suggestions
|
| $response = Prism::text() | ||
| ->using( | ||
| config('services.llm.provider', 'groq'), | ||
| config('services.llm.model', 'llama-3.1-8b-instant'), |
There was a problem hiding this comment.
It is safer to use a more capable model (like llama-3.1-70b or 405b) for structured extraction tasks if available, as 8b models often struggle with complex JSON schemas and disambiguation rules under long contexts.
| config('services.llm.model', 'llama-3.1-8b-instant'), | |
| config('services.llm.model', 'llama-3.1-70b-versatile'), |
| ->get()->map(function (Transaction $transaction) use ($budget) { | ||
| $transaction->amount_in_budget_currency = $transaction->amount; | ||
|
|
||
| if ($transaction->wallet->currency != $budget->currency) { |
There was a problem hiding this comment.
In a tight loop within a map operation, calling getRate (which may hit the database or external APIs) is inefficient (N+1 problem). Consider eager loading or bulk-fetching manual rates beforehand.
| if ($transaction->wallet->currency != $budget->currency) { | |
| if ($transaction->wallet->currency != $budget->currency) { | |
| // Suggest caching rates for the request duration or bulk fetching if possible | |
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency); |
| ); | ||
|
|
||
| // Bind a fresh collector for this run so render tools accumulate into it. | ||
| $collector = new BlockCollector(); |
There was a problem hiding this comment.
Bining the collector into the container as an instance within the run method of a service could lead to issues in long-running processes (like Octane) if not carefully cleared, or if multiple threads/workers share the container state.
| $collector = new BlockCollector(); | |
| $collector = new BlockCollector(); | |
| // Consider passing the collector explicitly to tools or ensuring clean lifecycle management. |
|
Coverage Report |
| $transaction->amount_in_budget_currency = $transaction->amount; | ||
|
|
||
| if ($transaction->wallet->currency != $budget->currency) { | ||
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency); |
There was a problem hiding this comment.
The N+1 problem identified in existing comments still exists here. While fetching the rate, consider using a runtime cache to avoid redundant database or API lookups for the same currency pair within the loop.
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency); | |
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency, $request->user()); |
| $amount = $this->number($row['amount'] ?? null); | ||
| $date = $this->normalizeDate($row['date'] ?? null); | ||
|
|
||
| if ($amount === null || $amount == 0.0 || $date === null) { |
There was a problem hiding this comment.
The comparison 0.0 with == can be problematic due to floating point precision. Use a small epsilon or check if the absolute value is above a threshold.
| if ($amount === null || $amount == 0.0 || $date === null) { | |
| if ($amount === null || abs($amount) < 0.00001 || $date === null) { |
| public function __construct() | ||
| { | ||
| $this->discoverPlugins(); | ||
| } |
There was a problem hiding this comment.
When parsing composer.lock, it is safer to specify JSON_THROW_ON_ERROR to catch malformed JSON immediately. This is already used but the catch block is empty; logging the failure would help debug environment issues.
| } | |
| } catch (\JsonException $e) { | |
| Log::error("Failed to parse composer.lock: {$e->getMessage()}"); | |
| return $this->installed_packages = []; | |
| } |
Attaching a shop receipt did nothing useful: the extraction result was read as an array when it is an object, so it always failed, and the generic extractor treats a receipt like a statement and returns a row per line item. Receipts now read the printed total as a single expense and attach the store as the party and a spending category when those already exist, ready to review and confirm.
| $response = Prism::text() | ||
| ->using( | ||
| config('services.llm.provider', 'groq'), | ||
| config('services.llm.model', 'llama-3.1-8b-instant'), |
There was a problem hiding this comment.
It is safer to use a more capable model (like llama-3.1-70b) for structured extraction tasks as 8b models often struggle with complex JSON schemas under long context.
| config('services.llm.model', 'llama-3.1-8b-instant'), | |
| config('services.llm.model', 'llama-3.1-70b-versatile'), |
|
|
||
| try { | ||
| $lockData = json_decode(file_get_contents($composerLockPath), true, 512, JSON_THROW_ON_ERROR); | ||
| } catch (\JsonException) { |
There was a problem hiding this comment.
The catch block for JSON_THROW_ON_ERROR is empty. Failures to parse composer.lock should be logged to aid in debugging environmental issues.
| } catch (\JsonException) { | |
| } catch (\JsonException $e) { | |
| Log::error("Failed to parse composer.lock: {$e->getMessage()}"); | |
| return $this->installedPackages = []; | |
| } |
The assistant judged requests by their vocabulary, so a general task wrapped in money words (a coding exercise framed as "reverse this linked list of my expenses", a math puzzle, an essay, trivia, politics) slipped past the finance persona and got answered. Both the conversational fallback and the acting agent are now told to weigh the real task rather than its wording, and to briefly decline anything unrelated and steer back to the user's finances instead of attempting it.
| protected OwnerResolver $ownerResolver, | ||
| protected ExchangeRateService $exchangeRateService | ||
| ) { | ||
| } |
There was a problem hiding this comment.
Processing a cursor result and calling a conversion service one-by-one can be slow for many transactions. While cursor() is memory efficient, the exchangeRateService calls should ideally be optimized via internal caching to prevent redundant lookups for the same currency pair in the same request.
| } | |
| return (float) $query->with('wallet')->cursor()->reduce(function ($carry, Transaction $transaction) use ($targetCurrency) { | |
| $walletCurrency = $transaction->wallet->currency; | |
| if ($walletCurrency == $targetCurrency) { | |
| return $carry + (float) $transaction->amount; | |
| } | |
| $converted = $this->exchangeRateService->convert( | |
| (float) $transaction->amount, | |
| $walletCurrency, | |
| $targetCurrency, | |
| $this->convertUser, | |
| ); | |
| if ($converted === null) { | |
| $this->unconvertedCurrencies[$walletCurrency] = true; | |
| return $carry; | |
| } | |
| return $carry + (float) $converted; | |
| }, 0); |
| $digits = preg_replace('/[\s,]/', '', trim($raw)); | ||
|
|
||
| return $val !== '' ? $val : null; | ||
| return (bool) preg_match('/^0\d{6,}$/', (string) $digits); |
There was a problem hiding this comment.
The regex ^0\d{6,}$ might incorrectly flag valid small decimal amounts formatted with leading zeros in some regions (e.g., '0.5000000'). It is better to ensure there is no decimal point in the digit string before rejecting as non-monetary.
| return (bool) preg_match('/^0\d{6,}$/', (string) $digits); | |
| return (bool) preg_match('/^0\d{6,}$/', (string) $digits) && !str_contains($raw, '.'); |
| return ['error' => 'A question is required.']; | ||
| } | ||
|
|
||
| $options = json_decode((string) ($arguments['options_json'] ?? ''), true); |
There was a problem hiding this comment.
Explicitly checking for null or handling json_decode failures with JSON_THROW_ON_ERROR would be more robust. If options_json is malformed, it currently returns a generic error which is fine, but logging the error would assist debugging.
| $options = json_decode((string) ($arguments['options_json'] ?? ''), true); | |
| try { | |
| $options = json_decode((string) ($arguments['options_json'] ?? ''), true, 512, JSON_THROW_ON_ERROR); | |
| } catch (\JsonException $e) { | |
| return ['error' => 'Invalid JSON in options_json.']; | |
| } |
Agent-proposed actions now persist in the shared agent-actions ledger and reuse its lifecycle and status/risk enums, instead of a bespoke table and hand-rolled constants. This is the groundwork for proposing and confirming a batch of actions as one unit. A proposal links to its chat through the package's polymorphic source (the session), with the rendering message and proposing tool kept in metadata, so it adopts the ledger through the package's own relationships rather than altering the package-owned table. Confirm and reject behaviour, including field overrides before confirming and the follow-up continuation after a prerequisite is created, is unchanged.
| // The user may edit the proposed fields before confirming. Only keys that | ||
| // already exist in the proposal may be overridden (never inject user_id), | ||
| // and the merged payload is re-validated through the same ownership rules. | ||
| $overrides = $request->input('overrides'); |
There was a problem hiding this comment.
Missing JSON_THROW_ON_ERROR while decoding overrides. If malformed JSON is sent, $overrides becomes null and logic might be skipped silently without reporting a syntax error.
| $overrides = $request->input('overrides'); | |
| $overrides = json_decode($request->input('overrides'), true, 512, JSON_THROW_ON_ERROR); |
| ->get()->map(function (Transaction $transaction) use ($budget) { | ||
| $transaction->amount_in_budget_currency = $transaction->amount; |
There was a problem hiding this comment.
This loop performs a database or external lookup for every transaction where the currency differs. This is an N+1 performance issue. You should eager load the wallet relation and pre-fetch or cache exchange rates.
| ->get()->map(function (Transaction $transaction) use ($budget) { | |
| $transaction->amount_in_budget_currency = $transaction->amount; | |
| ->get()->each(function (Transaction $transaction) use ($budget) { | |
| $transaction->amount_in_budget_currency = $transaction->amount; | |
| if ($transaction->wallet->currency !== $budget->currency) { | |
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency); | |
| $transaction->amount_in_budget_currency = $transaction->amount * ($exchangeRate ?? 1.0); | |
| } | |
| }); |
| $response = Prism::text() | ||
| ->using( | ||
| config('services.llm.provider', 'groq'), | ||
| config('services.llm.model', 'llama-3.1-8b-instant'), |
There was a problem hiding this comment.
Using llama-3.1-8b for complex structured extraction is risky. 8b models frequently hallucinate keys or break JSON syntax when context length increases. Consider llama-3.1-70b-versatile for higher reliability.
| config('services.llm.model', 'llama-3.1-8b-instant'), | |
| config('services.llm.model', 'llama-3.1-70b-versatile'), |
Prepares the Ailanthus beta: bumps the version so the release checks run against a fresh tag, records the 2.0.0-beta changelog covering the assistant, MCP server, imports and performance work, and corrects the codename doc.
Replace the inline commitlint config with the shared action, and validate on dev as well as main so commit messages are checked before they land, not only at the release, keeping dev and main consistent.
| // The user may edit the proposed fields before confirming. Only keys that | ||
| // already exist in the proposal may be overridden (never inject user_id), | ||
| // and the merged payload is re-validated through the same ownership rules. | ||
| $overrides = $request->input('overrides'); |
There was a problem hiding this comment.
The current logic uses $request->input('overrides') which may return a string or array depending on the request headers. Since this value is then used in array_filter and array_flip, it is safer to cast or ensure it is an array to prevent runtime errors if malformed input is sent.
| $overrides = $request->input('overrides'); | |
| $overrides = (array) $request->input('overrides', []); |
| $transaction->amount_in_budget_currency = $transaction->amount; | ||
|
|
||
| if ($transaction->wallet->currency != $budget->currency) { | ||
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency); |
There was a problem hiding this comment.
When calculating converted spend, if the exchange rate is null (unconvertible), the multiplication will result in 0. This silently under-reports the budget spend. Since the BudgetProgressService now handles 'partial' flags, the controller should ideally check for null rates or provide a fallback that alerts the user.
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency); | |
| $exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency); | |
| $transaction->amount_in_budget_currency = $exchangeRate !== null ? $transaction->amount * $exchangeRate : 0; |
| } | ||
|
|
||
| try { | ||
| ChatTurnEvent::dispatch($message->chat_session_id, $message->id, 'settled'); |
There was a problem hiding this comment.
Broadcasting an event inside a database observer can be risky if the transaction fails after the observer runs. Consider using dispatch_after_commit() or a similar mechanism to ensure the WebSocket event only fires if the message status change is actually persisted.
| ChatTurnEvent::dispatch($message->chat_session_id, $message->id, 'settled'); | |
| ChatTurnEvent::dispatch($message->chat_session_id, $message->id, 'settled')->afterCommit(); |
First beta of the v2.0.0-beta.1 line, codenamed Ailanthus, the tree of heaven and the first in the tree-name series (fittingly, its name starts with "AI" for this AI-forward beta; see
RELEASES.md). Promotes the accumulateddevwork tomainfor a beta.Headline changes since v2.0.0-beta.1:
The release workflow generates the full changelog when the tag is cut.