Skip to content

chore(release): Cut the v2.0.0-beta.1 Ailanthus beta - #289

Merged
nfebe merged 74 commits into
mainfrom
dev
Jul 14, 2026
Merged

chore(release): Cut the v2.0.0-beta.1 Ailanthus beta#289
nfebe merged 74 commits into
mainfrom
dev

Conversation

@nfebe

@nfebe nfebe commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

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 accumulated dev work to main for a beta.

Headline changes since v2.0.0-beta.1:

  • MCP server: external AI clients (Claude Desktop, Cursor) can read and act on a user's finance data, authenticated with user-issued access tokens.
  • Assistant: streams turn progress over websockets, resumes a multi-step plan after a confirmation, and records per-user token usage.
  • Bank statement import (CAMT.053) with statement fees recorded as linked charges.
  • Exchange-rate and asset-price endpoints; financial-position and holdings analytics.
  • Admin console with engagement metrics and outreach.

The release workflow generates the full changelog when the tag is cut.

nfebe and others added 30 commits June 8, 2026 02:13
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.
Lantum-Brendan and others added 10 commits July 11, 2026 18:56
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.
@sourceant

sourceant Bot commented Jul 11, 2026

Copy link
Copy Markdown

Code Review Summary

This 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

  • Introduction of AgentRunner and TrakliHarness for a structured, multi-step AI assistant experience.
  • Full implementation of an MCP server allowing users to connect external clients like Claude Desktop to their data.
  • Shared write paths via TransactionWriter ensuring consistent validation between API and AI routes.
  • Enhanced budget tracking with cross-currency support.

💡 Minor Suggestions

  • Ensure overrides in AiController is always cast to an array.
  • Optimize exchange rate lookups with request-level caching.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

$response = Prism::text()
->using(
config('services.llm.provider', 'groq'),
config('services.llm.model', 'llama-3.1-8b-instant'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$collector = new BlockCollector();
$collector = new BlockCollector();
// Consider passing the collector explicitly to tools or ensuring clean lifecycle management.

@nfebe nfebe changed the title chore(release): Cut the v1.2.0-beta Aspen beta chore(release): Cut the v1.2.0-beta Ailanthus beta Jul 11, 2026
@github-actions

github-actions Bot commented Jul 11, 2026

Copy link
Copy Markdown

Coverage Report
PR coverage: 68.95%
Baseline: 68.95%
Change: ✅+0%

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

$transaction->amount_in_budget_currency = $transaction->amount;

if ($transaction->wallet->currency != $budget->currency) {
$exchangeRate = $this->exchangeRateService->getRate($transaction->wallet->currency, $budget->currency);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if ($amount === null || $amount == 0.0 || $date === null) {
if ($amount === null || abs($amount) < 0.00001 || $date === null) {

public function __construct()
{
$this->discoverPlugins();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
} 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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

$response = Prism::text()
->using(
config('services.llm.provider', 'groq'),
config('services.llm.model', 'llama-3.1-8b-instant'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block for JSON_THROW_ON_ERROR is empty. Failures to parse composer.lock should be logged to aid in debugging environmental issues.

Suggested change
} 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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

protected OwnerResolver $ownerResolver,
protected ExchangeRateService $exchangeRateService
) {
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
}
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

// 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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$overrides = $request->input('overrides');
$overrides = json_decode($request->input('overrides'), true, 512, JSON_THROW_ON_ERROR);

Comment on lines +478 to +479
->get()->map(function (Transaction $transaction) use ($budget) {
$transaction->amount_in_budget_currency = $transaction->amount;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
->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'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
config('services.llm.model', 'llama-3.1-8b-instant'),
config('services.llm.model', 'llama-3.1-70b-versatile'),

nfebe added 2 commits July 14, 2026 01:08
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@nfebe nfebe changed the title chore(release): Cut the v1.2.0-beta Ailanthus beta chore(release): Cut the v2.0.0-beta.1 Ailanthus beta Jul 14, 2026
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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

// 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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
$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');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
ChatTurnEvent::dispatch($message->chat_session_id, $message->id, 'settled');
ChatTurnEvent::dispatch($message->chat_session_id, $message->id, 'settled')->afterCommit();

@nfebe
nfebe merged commit 87da447 into main Jul 14, 2026
9 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants