Merge branch 'main' into feature/retirement-planning

This commit is contained in:
Guillem Arias Fauste
2026-05-27 15:54:01 +02:00
committed by GitHub
57 changed files with 819 additions and 146 deletions

View File

@@ -0,0 +1,60 @@
name: Label non-Gittensor PRs
on:
pull_request_target:
types:
- opened
- reopened
permissions:
pull-requests: write
jobs:
label-pr:
runs-on: ubuntu-latest
steps:
- name: Add not-gittensor label for matched authors
uses: actions/github-script@v7
env:
GITTENSOR_USERS: ${{ vars.GITTENSOR_USERS || '[]' }}
GITTENSOR_EXCEPTIONS: ${{ vars.GITTENSOR_EXCEPTIONS || '[]' }}
TARGET_LABEL: not-gittensor
with:
script: |
const parseList = (raw, name) => {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
core.setFailed(`${name} must be a JSON array.`);
return [];
}
return parsed.map((value) => String(value).toLowerCase());
} catch (error) {
core.setFailed(`Failed to parse ${name}: ${error.message}`);
return [];
}
};
const author = context.payload.pull_request.user.login.toLowerCase();
const users = new Set(parseList(process.env.GITTENSOR_USERS, 'GITTENSOR_USERS'));
const exceptions = new Set(parseList(process.env.GITTENSOR_EXCEPTIONS, 'GITTENSOR_EXCEPTIONS'));
if (users.has(author) || exceptions.has(author)) {
core.info(`No label needed for @${author}.`);
return;
}
const existingLabels = context.payload.pull_request.labels.map((label) => label.name);
if (existingLabels.includes(process.env.TARGET_LABEL)) {
core.info(`Label ${process.env.TARGET_LABEL} already present.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: [process.env.TARGET_LABEL],
});
core.info(`Added ${process.env.TARGET_LABEL} to PR #${context.payload.pull_request.number}.`);

View File

@@ -2,7 +2,7 @@ name: Deploy PR Preview
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
types: [opened, synchronize, reopened, labeled]
paths-ignore:
- 'charts/**'
- 'docs/**'
@@ -10,7 +10,9 @@ on:
jobs:
deploy-preview:
if: contains(github.event.pull_request.labels.*.name, 'preview-cf')
if: |
contains(github.event.pull_request.labels.*.name, 'preview-cf') &&
(github.event.action != 'labeled' || github.event.label.name == 'preview-cf')
name: Deploy to Cloudflare Containers
runs-on: ubuntu-latest
timeout-minutes: 15

View File

@@ -1 +1 @@
0.7.1-alpha.10
0.7.1-alpha.11

View File

@@ -9,7 +9,7 @@ WORKDIR /rails
# Install base packages
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 procps \
&& apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2 procps libjemalloc2 \
&& rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment
@@ -19,7 +19,7 @@ ENV RAILS_ENV="production" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" \
BUILD_COMMIT_SHA=${BUILD_COMMIT_SHA}
# Throw-away build stage to reduce size of final image
FROM base AS build

21
SECURITY.md Normal file
View File

@@ -0,0 +1,21 @@
# Security Policy
## Supported Versions
Use this section to tell people about which versions of your project are
currently being supported with security updates.
| Version | Supported |
| ------- | ------------------ |
| 5.1.x | :white_check_mark: |
| 5.0.x | :x: |
| 4.0.x | :white_check_mark: |
| < 4.0 | :x: |
## Reporting a Vulnerability
Use this section to tell people how to report a vulnerability.
Tell them where to go, how often they can expect to get an update on a
reported vulnerability, what to expect if the vulnerability is accepted or
declined, etc.

View File

@@ -5,8 +5,8 @@ class FamilyMerchantsController < ApplicationController
@breadcrumbs = [ [ t("breadcrumbs.home"), root_path ], [ t("breadcrumbs.merchants"), nil ] ]
# Show all merchants for this family
@family_merchants = Current.family.merchants.alphabetically
@provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically
@all_family_merchants = Current.family.merchants.alphabetically
@all_provider_merchants = Current.family.assigned_merchants_for(Current.user).where(type: "ProviderMerchant").alphabetically
# Show recently unlinked ProviderMerchants (within last 30 days)
# Exclude merchants that are already assigned to transactions (they appear in provider_merchants)
@@ -14,12 +14,15 @@ class FamilyMerchantsController < ApplicationController
.where(family: Current.family)
.recently_unlinked
.pluck(:merchant_id)
assigned_ids = @provider_merchants.pluck(:id)
assigned_ids = @all_provider_merchants.pluck(:id)
@unlinked_merchants = ProviderMerchant.where(id: recently_unlinked_ids - assigned_ids).alphabetically
@enhanceable_count = @provider_merchants.where(website_url: [ nil, "" ]).count
@enhanceable_count = @all_provider_merchants.where(website_url: [ nil, "" ]).count
@llm_available = Provider::Registry.get_provider(:openai).present?
@pagy_family_merchants, @family_merchants = pagy(@all_family_merchants, page_param: :family_page, limit: safe_per_page)
@pagy_provider_merchants, @provider_merchants = pagy(@all_provider_merchants, page_param: :provider_page, limit: safe_per_page)
render layout: "settings"
end

View File

@@ -4,13 +4,17 @@ class MessagesController < ApplicationController
before_action :set_chat
def create
@message = UserMessage.create!(
@message = UserMessage.new(
chat: @chat,
content: message_params[:content],
ai_model: message_params[:ai_model].presence || Chat.default_model
)
redirect_to chat_path(@chat, thinking: true)
if @message.save
redirect_to chat_path(@chat, thinking: true)
else
redirect_to chat_path(@chat), alert: @message.errors.full_messages.to_sentence
end
end
private

View File

@@ -200,6 +200,12 @@ class Settings::HostingsController < ApplicationController
redirect_to settings_hosting_path, notice: t(".success")
rescue Setting::ValidationError => error
# Preserve user-submitted OpenAI config so the form re-renders with their
# input intact (issue #1824). The form auto-submits on blur, so a partial
# entry (e.g. URI base before model) hits validation and would otherwise
# be wiped because the view reads from the unchanged Setting.* values.
@openai_uri_base_input = hosting_params[:openai_uri_base] if hosting_params.key?(:openai_uri_base)
@openai_model_input = hosting_params[:openai_model] if hosting_params.key?(:openai_model)
flash.now[:alert] = error.message
render :show, status: :unprocessable_entity
end

View File

@@ -35,7 +35,7 @@ class TradesController < ApplicationController
format.turbo_stream { stream_redirect_back_or_to account_path(@account) }
end
else
render :new, status: :unprocessable_entity
render :new, status: :unprocessable_entity, formats: [ :html ]
end
end
@@ -69,7 +69,7 @@ class TradesController < ApplicationController
end
end
else
render :show, status: :unprocessable_entity
render :show, status: :unprocessable_entity, formats: [ :html ]
end
end

View File

@@ -27,21 +27,27 @@ class UsersController < ApplicationController
end
else
was_ai_enabled = @user.ai_enabled
@user.update!(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
if @user.update(user_params.except(:redirect_to, :delete_profile_image))
@user.profile_image.purge if should_purge_profile_image?
# Add a special notice if AI was just enabled or disabled
notice = if !was_ai_enabled && @user.ai_enabled
"AI Assistant has been enabled successfully."
elsif was_ai_enabled && !@user.ai_enabled
"AI Assistant has been disabled."
# Add a special notice if AI was just enabled or disabled
notice = if !was_ai_enabled && @user.ai_enabled
"AI Assistant has been enabled successfully."
elsif was_ai_enabled && !@user.ai_enabled
"AI Assistant has been disabled."
else
t(".success")
end
respond_to do |format|
format.html { handle_redirect(notice) }
format.json { head :ok }
end
else
t(".success")
end
respond_to do |format|
format.html { handle_redirect(notice) }
format.json { head :ok }
respond_to do |format|
format.html { redirect_to settings_profile_path, alert: @user.errors.full_messages.to_sentence }
format.json { render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity }
end
end
end
end

View File

@@ -7,9 +7,9 @@ export default class extends Controller {
const query = this.inputTarget.value.toLocaleLowerCase().trim();
let visibleCount = 0;
this.itemTargets.forEach(item => {
const name = item.dataset.bankName?.toLocaleLowerCase() ?? "";
const match = name.includes(query);
this.itemTargets.forEach((item) => {
const haystack = (item.dataset.bankSearch ?? "").toLocaleLowerCase();
const match = haystack.includes(query);
item.style.display = match ? "" : "none";
if (match) visibleCount++;
});

View File

@@ -2,7 +2,10 @@ class Account < ApplicationRecord
include AASM, Syncable, Monetizable, Chartable, Linkable, Enrichable, Anchorable, Reconcileable, TaxTreatable
before_validation :assign_default_owner, if: -> { owner_id.blank? }
before_destroy :capture_account_statement_ids_to_move
before_destroy :cleanup_transfers
after_destroy_commit :move_account_statements_to_inbox
validates :name, :balance, :currency, presence: true
@@ -543,4 +546,12 @@ class Account < ApplicationRecord
updated_at: Time.current
)
end
def cleanup_transfers
transaction_ids = entries.where(entryable_type: "Transaction").pluck(:entryable_id)
transfers = Transfer.where(inflow_transaction_id: transaction_ids).or(Transfer.where(outflow_transaction_id: transaction_ids))
transfers.find_each(&:destroy!)
end
end

View File

@@ -11,8 +11,11 @@ class IbkrAccount::HistoricalBalancesSync
return unless account.present?
return if normalized_rows.empty?
rows = balance_rows
return if rows.empty?
account.balances.upsert_all(
balance_rows,
rows,
unique_by: %i[account_id date currency]
)
end
@@ -109,12 +112,35 @@ class IbkrAccount::HistoricalBalancesSync
def balance_rows
current_time = Time.current
trade_flows_by_date # ensure @failed_fx_dates is populated before iterating
normalized_rows.each_with_index.map do |row, index|
normalized_rows.each_with_index.filter_map do |row, index|
next if @failed_fx_dates.include?(row[:date])
previous_row = index.zero? ? nil : normalized_rows[index - 1]
start_cash_balance = previous_row ? previous_row[:cash] : row[:cash]
start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash]
# Derive market return directly from IBKR's equity data so Period Return
# matches IBKR without requiring third-party security price providers.
#
# nmf = Δnon_cash - net_buy_sell
# Δnon_cash : change in holdings value per IBKR equity summary (exact)
# net_buy_sell: sum of trade entry amounts converted to base currency
# (positive = buy, negative = sell; IBKR fx_rate_to_base applied)
#
# non_cash_adjustments absorbs net_buy_sell so the virtual column
# end_non_cash_balance = start + nmf + adjustments stays equal to row[:non_cash].
if previous_row
net_buy_sell = trade_flows_by_date[row[:date]] || 0
nmf = row[:non_cash] - start_non_cash_balance - net_buy_sell
non_cash_adj = net_buy_sell
else
# First-day row has no prior period to diff against, so both values are
# intentionally zero — not a bug, just an unavoidable bootstrap constraint.
nmf = 0
non_cash_adj = 0
end
{
account_id: account.id,
date: row[:date],
@@ -127,13 +153,44 @@ class IbkrAccount::HistoricalBalancesSync
cash_outflows: 0,
non_cash_inflows: 0,
non_cash_outflows: 0,
net_market_flows: 0,
net_market_flows: nmf,
cash_adjustments: row[:cash] - start_cash_balance,
non_cash_adjustments: row[:non_cash] - start_non_cash_balance,
non_cash_adjustments: non_cash_adj,
flows_factor: 1,
created_at: current_time,
updated_at: current_time
}
end
end
# Net value of all trades on each date, in account base currency.
# Uses the IBKR-provided fx_rate_to_base stored on each Trade entry so the
# conversion is exact and consistent with IBKR's own calculations.
# Positive = net buy (cash out), negative = net sell (cash in).
def trade_flows_by_date
@trade_flows_by_date ||= begin
@failed_fx_dates = []
if account
account.entries
.joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'")
.where.not(trades: { qty: 0 })
.includes(:entryable)
.each_with_object(Hash.new(0)) do |entry, flows|
custom_rate = entry.entryable.exchange_rate
base_amount = Money.new(entry.amount, entry.currency)
.exchange_to(account_currency, custom_rate: custom_rate, date: entry.date)
.amount
flows[entry.date] += base_amount
rescue Money::ConversionError
Rails.logger.warn(
"IbkrAccount::HistoricalBalancesSync - No FX rate for #{entry.currency}#{account_currency} " \
"on #{entry.date}; balance row for this date will not be persisted"
)
@failed_fx_dates << entry.date
end
else
{}
end
end
end
end

View File

@@ -38,8 +38,16 @@ class Transfer < ApplicationRecord
# Once transfer is destroyed, we need to mark the denormalized kind fields on the transactions
def destroy!
Transfer.transaction do
inflow_transaction.update!(kind: "standard")
outflow_transaction.update!(kind: "standard")
[ inflow_transaction, outflow_transaction ].each do |transaction|
next if transaction.nil?
next unless Transaction.exists?(transaction.id)
begin
transaction.update!(kind: "standard")
rescue ActiveRecord::RecordNotFound
rescue NoMethodError
next
end
end
super
end
end

View File

@@ -51,6 +51,7 @@ class User < ApplicationRecord
validates :password, length: { minimum: 8 }, allow_nil: true
normalizes :email, with: ->(email) { email.strip.downcase }
normalizes :unconfirmed_email, with: ->(email) { email&.strip&.downcase }
normalizes :locale, with: ->(locale) { locale.presence }
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }

View File

@@ -12,7 +12,7 @@
<%= f.label :role, t(".filters.role"), class: "block text-sm font-medium text-primary mb-1" %>
<%= f.select :role,
options_for_select(
[[t(".filters.role_all"), ""], [t(".roles.guest"), "guest"], [t(".roles.member", default: "Member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"]],
[[t(".filters.role_all"), ""], [t(".roles.guest"), "guest"], [t(".roles.member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"]],
params[:role]
),
{},
@@ -75,7 +75,7 @@
</span>
<% elsif sub %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
<%= sub.active? ? "bg-green-100 text-green-800" : "bg-surface text-secondary" %>">
<%= sub.active? ? "bg-success/10 text-success" : "bg-surface text-secondary" %>">
<%= sub.status.humanize %>
</span>
<% else %>
@@ -87,7 +87,7 @@
<div class="border-t border-primary">
<table class="w-full">
<thead class="bg-surface-default border-b border-primary">
<thead class="bg-surface border-b border-primary">
<tr>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.user") %></th>
<th class="px-4 py-2 text-left text-xs font-medium text-secondary uppercase"><%= t(".table.last_login") %></th>
@@ -123,7 +123,7 @@
<%= form.select :role,
options_for_select([
[t(".roles.guest"), "guest"],
[t(".roles.member", default: "Member"), "member"],
[t(".roles.member"), "member"],
[t(".roles.admin"), "admin"],
[t(".roles.super_admin"), "super_admin"]
], user.role),
@@ -139,7 +139,7 @@
<% if pending_invitations.any? %>
<tbody class="divide-y divide-alpha-black-200 theme-dark:divide-alpha-white-200 border-t border-dashed border-primary">
<% pending_invitations.each do |invitation| %>
<tr class="bg-red-50/30 dark:bg-red-950/20">
<tr class="bg-destructive/5">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<%= icon "mail", class: "w-5 h-5 text-secondary shrink-0" %>
@@ -160,7 +160,7 @@
<button type="submit"
data-admin-invitation-delete-target="button"
data-action="click->admin-invitation-delete#handleClick"
class="text-sm text-red-600 hover:text-red-800 border border-red-300 rounded-lg px-2 py-1 hover:bg-red-50 transition-colors">
class="text-sm text-destructive border border-destructive rounded-lg px-2 py-1 hover:bg-destructive/10 transition-colors">
<%= t(".invitations.delete") %>
</button>
<% end %>
@@ -198,9 +198,9 @@
</div>
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
<%= t(".roles.member", default: "Member") %>
<%= t(".roles.member") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.member", default: "Basic user access. Can manage their own accounts, transactions, and settings.") %></p>
<p class="text-secondary"><%= t(".role_descriptions.member") %></p>
</div>
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
@@ -209,7 +209,7 @@
<p class="text-secondary"><%= t(".role_descriptions.admin") %></p>
</div>
<div class="flex items-start gap-3">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 shrink-0">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-success/10 text-success shrink-0">
<%= t(".roles.super_admin") %>
</span>
<p class="text-secondary"><%= t(".role_descriptions.super_admin") %></p>

View File

@@ -28,7 +28,7 @@
<div class="space-y-2 max-h-80 overflow-y-auto">
<% @aspsps.each do |aspsp| %>
<div data-bank-search-target="item" data-bank-name="<%= aspsp[:name].to_s.downcase(:fold) %>">
<div data-bank-search-target="item" data-bank-search="<%= [ aspsp[:name], aspsp[:bic] ].compact_blank.join(" ").downcase(:fold) %>">
<%= button_to authorize_enable_banking_item_path(@enable_banking_item),
method: :post,
params: { aspsp_name: aspsp[:name], new_connection: @new_connection },

View File

@@ -19,10 +19,10 @@
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p><%= t(".family_title", moniker: family_moniker) %></p>
<span class="text-subdued">&middot;</span>
<p><%= @family_merchants.count %></p>
<p><%= @all_family_merchants.count %></p>
</div>
<% if @family_merchants.any? %>
<% if @all_family_merchants.any? %>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="bg-container rounded-lg shadow-border-xs overflow-x-auto">
<table class="w-full">
@@ -52,13 +52,17 @@
</div>
</div>
<% end %>
<div class="pt-4">
<%= render "shared/pagination", pagy: @pagy_family_merchants %>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
<p><%= t(".provider_title") %></p>
<span class="text-subdued">&middot;</span>
<p><%= @provider_merchants.count %></p>
<p><%= @all_provider_merchants.count %></p>
</div>
<div class="p-4 bg-container-inset border border-secondary rounded-lg">
@@ -84,7 +88,7 @@
</div>
<% end %>
<% if @provider_merchants.any? %>
<% if @all_provider_merchants.any? %>
<div class="rounded-xl bg-container-inset space-y-1 p-1">
<div class="bg-container rounded-lg shadow-border-xs overflow-x-auto">
<table class="w-full">
@@ -106,6 +110,10 @@
<p class="text-secondary text-sm text-center"><%= t(".provider_empty", moniker: family_moniker_downcase) %></p>
</div>
<% end %>
<div class="pt-4">
<%= render "shared/pagination", pagy: @pagy_provider_merchants %>
</div>
</section>
<% if @unlinked_merchants.any? %>

View File

@@ -67,7 +67,7 @@
<%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>
</h1>
<p class="text-secondary text-sm">
<%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product: product_name) %>
<%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize, product_name: product_name) %>
</p>
</div>
</div>

View File

@@ -122,7 +122,7 @@
<%= render "imports/import_option",
type: "TransactionImport",
icon_name: "bar-chart-2",
icon_bg_class: "bg-gray-tint-5",
icon_bg_class: "bg-surface-inset",
icon_text_class: "text-subdued",
label: t(".import_ynab"),
enabled: false,

View File

@@ -34,6 +34,7 @@
<%= form.text_field :brand_fetch_client_id,
label: t(".label"),
type: "password",
autocomplete: "new-password",
placeholder: t(".placeholder"),
value: ENV.fetch("BRAND_FETCH_CLIENT_ID", Setting.brand_fetch_client_id),
disabled: ENV["BRAND_FETCH_CLIENT_ID"].present?,

View File

@@ -30,7 +30,7 @@
<%= form.text_field :openai_uri_base,
label: t(".uri_base_label"),
placeholder: t(".uri_base_placeholder"),
value: Setting.openai_uri_base,
value: @openai_uri_base_input || Setting.openai_uri_base,
autocomplete: "off",
autocapitalize: "none",
spellcheck: "false",
@@ -41,7 +41,7 @@
<%= form.text_field :openai_model,
label: t(".model_label"),
placeholder: t(".model_placeholder"),
value: Setting.openai_model,
value: @openai_model_input || Setting.openai_model,
autocomplete: "off",
autocapitalize: "none",
spellcheck: "false",

View File

@@ -17,18 +17,24 @@
<%= tag.details open: open,
class: "group bg-container shadow-border-xs rounded-xl #{border_class}",
data: details_data do %>
<summary class="flex items-center gap-3 min-h-15 px-4 py-3.5 cursor-pointer rounded-xl list-none [&::-webkit-details-marker]:hidden">
<%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %>
<div class="flex items-center gap-2 flex-wrap min-w-0 flex-1">
<h3 class="text-sm font-medium text-primary"><%= entry[:title] %></h3>
<%= render "settings/providers/maturity_badge", label: maturity_lbl %>
</div>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status_pill %>
<%= sync_action if sync_action %>
<summary class="min-h-15 px-4 py-3.5 cursor-pointer rounded-xl list-none [&::-webkit-details-marker]:hidden">
<div class="flex items-center gap-3">
<%= icon "chevron-right", size: "sm", class: "!w-3.5 !h-3.5 text-secondary group-open:rotate-90 transition-transform" %>
<div class="flex items-center flex-wrap gap-2 min-w-0 flex-1">
<div class="flex items-center gap-2 shrink-0 flex-1">
<h3 class="text-sm font-medium text-primary"><%= entry[:title] %></h3>
<%= render "settings/providers/maturity_badge", label: maturity_lbl %>
</div>
<div class="flex items-center gap-2 shrink-0 group-open:hidden">
<% if meta.present? %>
<span class="text-xs text-subdued"><%= meta %></span>
<% end %>
<%= status_pill %>
</div>
</div>
<div class="group-open:hidden">
<%= sync_action if sync_action %>
</div>
</div>
</summary>
<div class="space-y-4 mt-4 px-4 pb-4">

View File

@@ -6,11 +6,21 @@
<%= icon "link-2", size: "sm", class: "text-secondary" %>
</span>
<% elsif transaction.transfer.pending? %>
<span class="hidden lg:inline-flex items-center rounded-full bg-surface-inset px-2 py-0.5 text-xs font-medium text-secondary">
<%= t("transactions.transfer_match.auto_matched") %>
<span class="hidden lg:inline">
<%= render DS::Pill.new(
label: t("transactions.transfer_match.auto_matched"),
tone: :neutral,
marker: false,
show_dot: false
) %>
</span>
<span class="inline-flex lg:hidden items-center rounded-full bg-surface-inset px-2 py-0.5 text-xs font-medium text-secondary">
<%= t("transactions.transfer_match.auto_matched_short") %>
<span class="inline lg:hidden">
<%= render DS::Pill.new(
label: t("transactions.transfer_match.auto_matched_short"),
tone: :neutral,
marker: false,
show_dot: false
) %>
</span>
<%= button_to transfer_path(transaction.transfer, transfer: { status: "confirmed" }),

View File

@@ -1,5 +1,15 @@
#!/bin/bash -e
# Use jemalloc to reduce memory fragmentation if available
JEMALLOC="/usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2"
if [ -f "$JEMALLOC" ] && [ -z "${DISABLE_JEMALLOC}" ]; then
export LD_PRELOAD="${LD_PRELOAD:+$LD_PRELOAD:}$JEMALLOC"
else
[ -n "${DISABLE_JEMALLOC}" ] \
&& echo "WARNING: jemalloc disabled via DISABLE_JEMALLOC" \
|| echo "WARNING: jemalloc not found at $JEMALLOC, skipping"
fi
# If running the rails server then create or migrate existing database
if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then
./bin/rails db:prepare

View File

@@ -5,16 +5,22 @@ All notable changes to the Sure Helm chart will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.7.1] - 2026-05-31]
### Changed
- Bumped `pipelock.image.tag` from `2.0.0` to `2.2.0` (three minor releases behind latest). Floating `@v2` CI action pin picks up patch/minor updates automatically.
- Refreshed pipelock feature notes in the chart README, `docs/hosting/pipelock.md`, and `pipelock.example.yaml` to reference the upstream changelog instead of a single version.
- Bumped `pipelock.image.tag` from `2.2.0` to `2.5.0`. Picks up three releases of scanner, federation, and audit work — see the [pipelock changelog](https://github.com/luckyPipewrench/pipelock/blob/main/CHANGELOG.md) for the full surface.
- Refreshed pipelock feature notes in the chart README, `docs/hosting/pipelock.md`, and `pipelock.example.yaml` to call out 2.5 highlights (Audit Packet v0, request-body prompt-injection blocking, SPIFFE-strict inbound envelopes, scanner attribution on MCP block receipts).
- Expanded the `pipelock.extraConfig` escape-hatch comment to reference the new sections available in 2.5 (browser_shield, mediation_envelope, redaction, learn, media_policy, a2a_scanning, emit).
- Defaulted `pipelock.mcpToolPolicy.enabled` to `false`. Pipelock 2.x rejects an enabled `mcp_tool_policy` with no rules, so the prior chart default (`enabled: true`, empty rules) hard-failed config validation on startup. Operators who want tool policy active must now set `enabled: true` and provide at least one entry in `pipelock.mcpToolPolicy.rules`.
### Added
- `pipelock.requestBodyScanning` (pipelock 2.5+): structured config for scanning outbound request bodies for prompt-injection and bodies/sensitive headers for DLP payloads. Disabled by default to preserve existing chart behavior; opt in with `enabled: true`.
- `pipelock.healthWatchdog` (pipelock 2.4+): structured config for the wedge-detection watchdog. Operators can opt into per-subsystem detail in `/health` responses via `exposeSubsystems: true`.
- `pipelock.mcpToolPolicy.rules`: structured Helm values for rendering `mcp_tool_policy.rules`, including redirect-profile references.
- `asserts.tpl` guard that fails `helm template`/`helm install` when `pipelock.mcpToolPolicy.enabled=true` is paired with an empty `rules` list, surfacing the pipelock validation error at render time instead of container startup.
- README: CI scan status badge for the pipelock workflow.
## [0.6.9-alpha] - 2026-03-24
## [0.6.9] - 2026-03-31
### Changed
- Bumped `pipelock.image.tag` from `1.5.0` to `2.0.0`
@@ -58,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Renamed `_asserts.tpl` to `asserts.tpl` — Helm's `_` prefix convention prevented guards from executing
## [0.6.7-alpha] - 2026-01-10
## [0.6.7] - 2026-01-31
### Added
- **Redis Sentinel support for Sidekiq high availability**: Application now automatically detects and configures Sidekiq to use Redis Sentinel when `redisOperator.mode=sentinel` and `redisOperator.sentinel.enabled=true`

View File

@@ -2,8 +2,8 @@ apiVersion: v2
name: sure
description: Official Helm chart for deploying the Sure Rails app (web + Sidekiq) on Kubernetes with optional HA PostgreSQL (CloudNativePG) and Redis.
type: application
version: 0.7.1-alpha.10
appVersion: "0.7.1-alpha.10"
version: 0.7.1-alpha.11
appVersion: "0.7.1-alpha.11"
kubeVersion: ">=1.25.0-0"

View File

@@ -645,7 +645,7 @@ hpa:
- **Forward proxy** (port 8888): Scans outbound HTTPS from Faraday-based AI clients. Auto-injected via `HTTPS_PROXY` env vars when enabled.
- **MCP reverse proxy** (port 8889): Scans inbound MCP traffic from external AI assistants.
Recent pipelock releases add enhanced tool poisoning detection (full JSON schema scanning), per-read kill switch preemption, trusted domain allowlisting, MCP tool redirect profiles, signed action receipts, per-pattern DLP warn mode, and the `pipelock posture verify` / `pipelock session` CLIs. Process sandboxing and attack simulation are also available via `extraConfig` and CLI. See the [pipelock changelog](https://github.com/luckyPipewrench/pipelock/releases) for details.
Recent pipelock releases add the Audit Packet v0 schema and language-portable verifiers (Go, TypeScript, Rust), request-body prompt-injection blocking, SPIFFE-strict inbound mediation envelopes, scanner attribution on MCP block receipts, enhanced tool poisoning detection, per-read kill switch preemption, trusted domain allowlisting, MCP tool redirect profiles, signed action receipts, per-pattern DLP warn mode, learn-and-lock behavioural contracts, the wedge-detection health watchdog, and the `pipelock posture verify` / `pipelock session` / `pipelock doctor` CLIs. Browser Shield, process sandboxing, and attack simulation are available via `extraConfig` and CLI. See the [pipelock changelog](https://github.com/luckyPipewrench/pipelock/releases) for details.
### Enabling Pipelock
@@ -653,7 +653,7 @@ Recent pipelock releases add enhanced tool poisoning detection (full JSON schema
pipelock:
enabled: true
image:
tag: "2.2.0"
tag: "2.5.0"
mode: balanced # strict, balanced, or audit
```
@@ -677,12 +677,45 @@ pipelock:
mcpToolPolicy:
enabled: true
action: redirect # or use per-rule action overrides
rules:
- name: redirect-fetch
toolPattern: "^(fetch|web_fetch)$"
action: redirect
redirectProfile: safe-fetch
redirectProfiles:
safe-fetch:
exec: ["/pipelock", "internal-redirect", "fetch-proxy"]
reason: "Route fetch calls through audited proxy"
```
### Request body scanning (pipelock 2.5+)
Pipelock 2.5 added prompt-injection detection on outbound request bodies (JSON, form-encoded, raw text, WebSocket frames). When enabled, findings hard-block non-provider destinations even when `action: warn`; trusted provider hosts (OpenAI, Anthropic, etc.) remain exempt through the response-scanning exemption list.
```yaml
pipelock:
requestBodyScanning:
enabled: true
action: warn # warn or block
maxBodyBytes: 5242880 # 5 MB; fail-closed above this
scanHeaders: true
headerMode: sensitive # "sensitive" or "all"
```
Disabled by default. Roll out with `action: warn` first to observe findings in logs without blocking, then flip to `action: block` once the false-positive rate is acceptable.
### Health watchdog
The wedge-detection watchdog returns 503 on `/health` when a subsystem heartbeat (proxy hot path, MCP listener, rules-engine reload watcher) goes stale. Enabled by default in pipelock; the chart exposes the controls so operators can opt into per-subsystem detail in the health payload:
```yaml
pipelock:
healthWatchdog:
enabled: true
intervalSeconds: 2
exposeSubsystems: true # adds per-subsystem boolean map to /health
```
### Validating your config
Pipelock includes CLI tools for config validation:
@@ -693,6 +726,9 @@ pipelock simulate --config pipelock.yaml
# Score your config's security posture (0-100)
pipelock audit score --config pipelock.yaml
# Report whether configured protections are actually enforceable
pipelock doctor
```
### Exposing MCP to external AI assistants
@@ -811,7 +847,7 @@ See `values.yaml` for the complete configuration surface, including:
- `migrations.*`: strategy job or initContainer
- `simplefin.encryption.*`: enable + backfill options
- `cronjobs.*`: custom CronJobs
- `pipelock.*`: AI agent security proxy (forward proxy, MCP reverse proxy, DLP, injection scanning, trusted domains, tool redirect profiles, logging, serviceMonitor, ingress, PDB, extraConfig)
- `pipelock.*`: AI agent security proxy (forward proxy, MCP reverse proxy, DLP, injection scanning, request-body scanning, health watchdog, trusted domains, tool redirect profiles, logging, serviceMonitor, ingress, PDB, extraConfig)
- `service.*`, `ingress.*`, `serviceMonitor.*`, `hpa.*`
## Helm tests

View File

@@ -21,3 +21,18 @@ Mutual exclusivity and configuration guards
{{- if and $extEnabled (not $plEnabled) $requirePL -}}
{{- fail "pipelock.requireForExternalAssistant is true but pipelock.enabled is false. Enable pipelock (pipelock.enabled=true) when using rails.externalAssistant, or set pipelock.requireForExternalAssistant=false." -}}
{{- end -}}
{{/*
Pipelock 2.x rejects an enabled mcp_tool_policy with no rules; surface this
at helm template time instead of waiting for the container to crash-loop.
*/}}
{{- if $plEnabled -}}
{{- $mtp := .Values.pipelock.mcpToolPolicy | default (dict) -}}
{{- if hasKey $mtp "enabled" -}}
{{- if $mtp.enabled -}}
{{- if eq (len ($mtp.rules | default (list))) 0 -}}
{{- fail "pipelock.mcpToolPolicy.enabled=true requires at least one entry in pipelock.mcpToolPolicy.rules. Pipelock rejects an enabled tool policy with no rules." -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- end -}}

View File

@@ -65,6 +65,34 @@
{{- $chainWindow = int (.Values.pipelock.toolChainDetection.windowSize | default 20) -}}
{{- $chainGap = int (.Values.pipelock.toolChainDetection.maxGap | default 3) -}}
{{- end -}}
{{- $rbsEnabled := false -}}
{{- $rbsAction := "warn" -}}
{{- $rbsMaxBytes := 5242880 -}}
{{- $rbsScanHeaders := true -}}
{{- $rbsHeaderMode := "sensitive" -}}
{{- if .Values.pipelock.requestBodyScanning -}}
{{- if hasKey .Values.pipelock.requestBodyScanning "enabled" -}}
{{- $rbsEnabled = .Values.pipelock.requestBodyScanning.enabled -}}
{{- end -}}
{{- $rbsAction = .Values.pipelock.requestBodyScanning.action | default "warn" -}}
{{- $rbsMaxBytes = int (.Values.pipelock.requestBodyScanning.maxBodyBytes | default 5242880) -}}
{{- if hasKey .Values.pipelock.requestBodyScanning "scanHeaders" -}}
{{- $rbsScanHeaders = .Values.pipelock.requestBodyScanning.scanHeaders -}}
{{- end -}}
{{- $rbsHeaderMode = .Values.pipelock.requestBodyScanning.headerMode | default "sensitive" -}}
{{- end -}}
{{- $hwEnabled := true -}}
{{- $hwInterval := 2 -}}
{{- $hwExpose := false -}}
{{- if .Values.pipelock.healthWatchdog -}}
{{- if hasKey .Values.pipelock.healthWatchdog "enabled" -}}
{{- $hwEnabled = .Values.pipelock.healthWatchdog.enabled -}}
{{- end -}}
{{- $hwInterval = int (.Values.pipelock.healthWatchdog.intervalSeconds | default 2) -}}
{{- if hasKey .Values.pipelock.healthWatchdog "exposeSubsystems" -}}
{{- $hwExpose = .Values.pipelock.healthWatchdog.exposeSubsystems -}}
{{- end -}}
{{- end -}}
{{- $logFormat := "json" -}}
{{- $logOutput := "stdout" -}}
{{- $logIncludeAllowed := false -}}
@@ -128,6 +156,25 @@ data:
mcp_tool_policy:
enabled: {{ $mcpPolicyEnabled }}
action: {{ $mcpPolicyAction }}
{{- if and .Values.pipelock.mcpToolPolicy .Values.pipelock.mcpToolPolicy.rules }}
rules:
{{- range .Values.pipelock.mcpToolPolicy.rules }}
- name: {{ required "pipelock.mcpToolPolicy.rules[].name is required" .name | quote }}
tool_pattern: {{ required "pipelock.mcpToolPolicy.rules[].toolPattern is required" .toolPattern | quote }}
{{- if .argPattern }}
arg_pattern: {{ .argPattern | quote }}
{{- end }}
{{- if .argKey }}
arg_key: {{ .argKey | quote }}
{{- end }}
{{- if .action }}
action: {{ .action | quote }}
{{- end }}
{{- if .redirectProfile }}
redirect_profile: {{ .redirectProfile | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- if and .Values.pipelock.mcpToolPolicy .Values.pipelock.mcpToolPolicy.redirectProfiles }}
redirect_profiles:
{{- toYaml .Values.pipelock.mcpToolPolicy.redirectProfiles | nindent 8 }}
@@ -140,6 +187,16 @@ data:
action: {{ $chainAction }}
window_size: {{ $chainWindow }}
max_gap: {{ $chainGap }}
request_body_scanning:
enabled: {{ $rbsEnabled }}
action: {{ $rbsAction }}
max_body_bytes: {{ $rbsMaxBytes }}
scan_headers: {{ $rbsScanHeaders }}
header_mode: {{ $rbsHeaderMode }}
health_watchdog:
enabled: {{ $hwEnabled }}
interval_seconds: {{ $hwInterval }}
expose_subsystems: {{ $hwExpose }}
logging:
format: {{ $logFormat }}
output: {{ $logOutput }}

View File

@@ -497,7 +497,7 @@ pipelock:
enabled: false
image:
repository: ghcr.io/luckypipewrench/pipelock
tag: "2.2.0"
tag: "2.5.0"
pullPolicy: IfNotPresent
imagePullSecrets: []
replicas: 1
@@ -530,8 +530,17 @@ pipelock:
originPolicy: rewrite # rewrite, forward, or strip
# MCP tool policy: pre-execution rules for tool calls (shell obfuscation, etc.)
mcpToolPolicy:
enabled: true
# Enable only when at least one rule is configured; pipelock rejects an
# enabled mcp_tool_policy with no rules.
enabled: false
action: warn
# Example:
# rules:
# - name: redirect-fetch
# toolPattern: "^(fetch|web_fetch)$"
# action: redirect
# redirectProfile: safe-fetch
rules: []
# Redirect profiles: route matched tool calls to audited handler programs instead
# of blocking. The handler returns a synthetic MCP response. Fail-closed on error.
# Example:
@@ -550,6 +559,24 @@ pipelock:
action: warn
windowSize: 20
maxGap: 3
# Request body scanning (pipelock 2.5+): detect prompt-injection payloads
# in outbound request bodies (JSON, form-encoded, raw text, WebSocket frames).
# In enforce mode, prompt-injection findings hard-block non-provider
# destinations even when action is "warn"; trusted provider hosts (OpenAI,
# Anthropic, etc.) remain exempt via the response_scanning exemption list.
requestBodyScanning:
enabled: false
action: warn # warn or block
maxBodyBytes: 5242880 # 5 MB; fail-closed above this
scanHeaders: true # scan request headers for DLP
headerMode: sensitive # "sensitive" or "all"
# Health watchdog (pipelock 2.4+): /health returns 503 when any subsystem
# heartbeat goes stale. Enabled by default in pipelock; exposed here so
# operators can opt into per-subsystem detail in the health payload.
healthWatchdog:
enabled: true
intervalSeconds: 2
exposeSubsystems: false # include per-subsystem map in /health response
service:
type: ClusterIP
resources:
@@ -601,8 +628,10 @@ pipelock:
includeBlocked: true
# Escape hatch: ADDITIONAL config sections appended to pipelock.yaml.
# Use for sections not covered by structured values above (session_profiling,
# data_budget, adaptive_enforcement, kill_switch, sandbox, reverse_proxy, etc.)
# Use for sections not covered by structured values above. Examples:
# session_profiling, data_budget, adaptive_enforcement, kill_switch, sandbox,
# reverse_proxy, redaction, browser_shield, mediation_envelope, learn,
# media_policy, a2a_scanning, emit (OTel agent.threat.detection attributes).
# Do NOT duplicate keys already rendered above - behavior is parser-dependent.
extraConfig: {}

View File

@@ -112,7 +112,7 @@ x-rails-env: &rails_env
services:
pipelock:
image: ghcr.io/luckypipewrench/pipelock:latest # pin to a specific version (e.g., :2.2.0) for production
image: ghcr.io/luckypipewrench/pipelock:latest # pin to a specific version (e.g., :2.5.0) for production
container_name: pipelock
hostname: pipelock
restart: unless-stopped

View File

@@ -61,13 +61,13 @@ de:
rows_label: Zeilen
unassigned_account: Neues Konto für nicht zugewiesene Zeilen erstellen?
show:
account_mapping_description: Weise alle Konten aus deiner importierten Datei den bestehenden Konten in %{product} zu. Du kannst auch neue Konten hinzufügen oder sie ohne Kategorie lassen.
account_mapping_description: Weise alle Konten aus deiner importierten Datei den bestehenden Konten in %{product_name} zu. Du kannst auch neue Konten hinzufügen oder sie ohne Kategorie lassen.
account_mapping_title: Konten zuweisen
account_type_mapping_description: Weise alle Kontotypen aus deiner importierten Datei den Kontotypen in %{product} zu.
account_type_mapping_description: Weise alle Kontotypen aus deiner importierten Datei den Kontotypen in %{product_name} zu.
account_type_mapping_title: Kontotypen zuweisen
category_mapping_description: Weise alle Kategorien aus deiner importierten Datei den bestehenden Kategorien in %{product} zu. Du kannst auch neue Kategorien hinzufügen oder sie ohne Kategorie lassen.
category_mapping_description: Weise alle Kategorien aus deiner importierten Datei den bestehenden Kategorien in %{product_name} zu. Du kannst auch neue Kategorien hinzufügen oder sie ohne Kategorie lassen.
category_mapping_title: Kategorien zuweisen
tag_mapping_description: Weise alle Tags aus deiner importierten Datei den bestehenden Tags in %{product} zu. Du kannst auch neue Tags hinzufügen oder sie ohne Kategorie lassen.
tag_mapping_description: Weise alle Tags aus deiner importierten Datei den bestehenden Tags in %{product_name} zu. Du kannst auch neue Tags hinzufügen oder sie ohne Kategorie lassen.
tag_mapping_title: Tags zuweisen
uploads:
show:

View File

@@ -155,17 +155,17 @@ en:
show:
invalid_data: "You have invalid data, please edit until all errors are resolved"
account_mapping_description: Assign all of your imported file's accounts to
Maybe's existing accounts. You can also add new accounts or leave them
%{product_name}'s existing accounts. You can also add new accounts or leave them
uncategorized.
account_mapping_title: Assign your accounts
account_type_mapping_description: Assign all of your imported file's account
types to Maybe's
types to %{product_name}'s
account_type_mapping_title: Assign your account types
category_mapping_description: Assign all of your imported file's categories
to Maybe's existing categories. You can also add new categories or leave
to %{product_name}'s existing categories. You can also add new categories or leave
them uncategorized.
category_mapping_title: Assign your categories
tag_mapping_description: Assign all of your imported file's tags to Maybe's
tag_mapping_description: Assign all of your imported file's tags to %{product_name}'s
existing tags. You can also add new tags or leave them uncategorized.
tag_mapping_title: Assign your tags
uploads:

View File

@@ -61,13 +61,13 @@ es:
rows_label: Filas
unassigned_account: ¿Necesitas crear una nueva cuenta para las filas sin asignar?
show:
account_mapping_description: Asigna todas las cuentas de tu archivo importado a las cuentas existentes de Sure. También puedes añadir nuevas cuentas o dejarlas sin categorizar.
account_mapping_description: Asigna todas las cuentas de tu archivo importado a las cuentas existentes de %{product_name}. También puedes añadir nuevas cuentas o dejarlas sin categorizar.
account_mapping_title: Asigna tus cuentas
account_type_mapping_description: Asigna todos los tipos de cuenta de tu archivo importado a los de Sure.
account_type_mapping_description: Asigna todos los tipos de cuenta de tu archivo importado a los de %{product_name}.
account_type_mapping_title: Asigna tus tipos de cuenta
category_mapping_description: Asigna todas las categorías de tu archivo importado a las categorías existentes de Sure. También puedes añadir nuevas categorías o dejarlas sin categorizar.
category_mapping_description: Asigna todas las categorías de tu archivo importado a las categorías existentes de %{product_name}. También puedes añadir nuevas categorías o dejarlas sin categorizar.
category_mapping_title: Asigna tus categorías
tag_mapping_description: Asigna todas las etiquetas de tu archivo importado a las etiquetas existentes de Sure. También puedes añadir nuevas etiquetas o dejarlas sin categorizar.
tag_mapping_description: Asigna todas las etiquetas de tu archivo importado a las etiquetas existentes de %{product_name}. También puedes añadir nuevas etiquetas o dejarlas sin categorizar.
tag_mapping_title: Asigna tus etiquetas
uploads:
show:

View File

@@ -134,13 +134,13 @@ hu:
next: Tovább
show:
invalid_data: "Érvénytelen adatok találhatók, kérjük szerkeszd, amíg minden hibát ki nem javítasz"
account_mapping_description: Rendeld hozzá az importált fájl számláit a Maybe meglévő számláihoz. Új számlákat is hozzáadhatsz, vagy hagyhatod őket kategorizálatlanul.
account_mapping_description: Rendeld hozzá az importált fájl számláit a %{product_name} meglévő számláihoz. Új számlákat is hozzáadhatsz, vagy hagyhatod őket kategorizálatlanul.
account_mapping_title: Számlák hozzárendelése
account_type_mapping_description: Rendeld hozzá az importált fájl számlatípusait a Maybe típusaihoz.
account_type_mapping_description: Rendeld hozzá az importált fájl számlatípusait a %{product_name} típusaihoz.
account_type_mapping_title: Számlatípusok hozzárendelése
category_mapping_description: Rendeld hozzá az importált fájl kategóriáit a Maybe meglévő kategóriáihoz. Új kategóriákat is hozzáadhatsz, vagy hagyhatod őket kategorizálatlanul.
category_mapping_description: Rendeld hozzá az importált fájl kategóriáit a %{product_name} meglévő kategóriáihoz. Új kategóriákat is hozzáadhatsz, vagy hagyhatod őket kategorizálatlanul.
category_mapping_title: Kategóriák hozzárendelése
tag_mapping_description: Rendeld hozzá az importált fájl címkéit a Maybe meglévő címkéihez. Új címkéket is hozzáadhatsz, vagy hagyhatod őket kategorizálatlanul.
tag_mapping_description: Rendeld hozzá az importált fájl címkéit a %{product_name} meglévő címkéihez. Új címkéket is hozzáadhatsz, vagy hagyhatod őket kategorizálatlanul.
tag_mapping_title: Címkék hozzárendelése
uploads:
update:

View File

@@ -31,18 +31,18 @@ nb:
unassigned_account: Trenger å opprette en ny konto for utilordnede rader?
show:
account_mapping_description: Tilordne alle kontoene i den importerte filen din til
Maybes eksisterende kontoer. Du kan også legge til nye kontoer eller la dem
%{product_name}s eksisterende kontoer. Du kan også legge til nye kontoer eller la dem
være ukategorisert.
account_mapping_title: Tilordne kontoene dine
account_type_mapping_description: Tilordne alle kontotypene i den importerte filen din til
Maybes
%{product_name}s
account_type_mapping_title: Tilordne kontotypene dine
category_mapping_description: Tilordne alle kategoriene i den importerte filen din til
Maybes eksisterende kategorier. Du kan også legge til nye kategorier eller la dem
%{product_name}s eksisterende kategorier. Du kan også legge til nye kategorier eller la dem
være ukategorisert.
category_mapping_title: Tilordne kategoriene dine
tag_mapping_description: Tilordne alle tagene i den importerte filen din til
Maybes eksisterende tagger. Du kan også legge til nye tagger eller la dem
%{product_name}s eksisterende tagger. Du kan også legge til nye tagger eller la dem
være ukategorisert.
tag_mapping_title: Tilordne tagene dine
uploads:

View File

@@ -37,13 +37,13 @@ nl:
rows_label: Rijen
unassigned_account: Moet u een nieuw account aanmaken voor niet-toegewezen rijen?
show:
account_mapping_description: Wijs alle accounts van uw geïmporteerde bestand toe aan bestaande %{product} accounts. U kunt ook nieuwe accounts toevoegen of ze ongecategoriseerd laten.
account_mapping_description: Wijs alle accounts van uw geïmporteerde bestand toe aan bestaande %{product_name} accounts. U kunt ook nieuwe accounts toevoegen of ze ongecategoriseerd laten.
account_mapping_title: Uw accounts toewijzen
account_type_mapping_description: Wijs alle accounttypen van uw geïmporteerde bestand toe aan %{product} accounttypen
account_type_mapping_description: Wijs alle accounttypen van uw geïmporteerde bestand toe aan %{product_name} accounttypen
account_type_mapping_title: Uw accounttypen toewijzen
category_mapping_description: Wijs alle categorieën van uw geïmporteerde bestand toe aan bestaande %{product} categorieën. U kunt ook nieuwe categorieën toevoegen of ze ongecategoriseerd laten.
category_mapping_description: Wijs alle categorieën van uw geïmporteerde bestand toe aan bestaande %{product_name} categorieën. U kunt ook nieuwe categorieën toevoegen of ze ongecategoriseerd laten.
category_mapping_title: Uw categorieën toewijzen
tag_mapping_description: Wijs alle tags van uw geïmporteerde bestand toe aan bestaande %{product} tags. U kunt ook nieuwe tags toevoegen of ze ongecategoriseerd laten.
tag_mapping_description: Wijs alle tags van uw geïmporteerde bestand toe aan bestaande %{product_name} tags. U kunt ook nieuwe tags toevoegen of ze ongecategoriseerd laten.
tag_mapping_title: Uw tags toewijzen
uploads:
show:

View File

@@ -74,13 +74,13 @@ pl:
rows_label: Wiersze
unassigned_account: Chcesz utworzyć nowe konto dla nieprzypisanych wierszy?
show:
account_mapping_description: Przypisz wszystkie konta z importowanego pliku do istniejących kont %{product}. Możesz też dodać nowe konta lub zostawić je bez przypisania.
account_mapping_description: Przypisz wszystkie konta z importowanego pliku do istniejących kont %{product_name}. Możesz też dodać nowe konta lub zostawić je bez przypisania.
account_mapping_title: Przypisz konta
account_type_mapping_description: Przypisz wszystkie typy kont z importowanego pliku do typów używanych w %{product}.
account_type_mapping_description: Przypisz wszystkie typy kont z importowanego pliku do typów używanych w %{product_name}.
account_type_mapping_title: Przypisz typy kont
category_mapping_description: Przypisz wszystkie kategorie z importowanego pliku do istniejących kategorii %{product}. Możesz też dodać nowe kategorie lub zostawić je bez przypisania.
category_mapping_description: Przypisz wszystkie kategorie z importowanego pliku do istniejących kategorii %{product_name}. Możesz też dodać nowe kategorie lub zostawić je bez przypisania.
category_mapping_title: Przypisz kategorie
tag_mapping_description: Przypisz wszystkie tagi z importowanego pliku do istniejących tagów %{product}. Możesz też dodać nowe tagi lub zostawić je bez przypisania.
tag_mapping_description: Przypisz wszystkie tagi z importowanego pliku do istniejących tagów %{product_name}. Możesz też dodać nowe tagi lub zostawić je bez przypisania.
tag_mapping_title: Przypisz tagi
uploads:
show:

View File

@@ -36,18 +36,18 @@ pt-BR:
unassigned_account: Precisa criar uma nova conta para linhas não atribuídas?
show:
account_mapping_description: Atribua todas as contas do seu arquivo importado às
contas existentes no %{product}. Você também pode adicionar novas contas ou deixá-las
contas existentes no %{product_name}. Você também pode adicionar novas contas ou deixá-las
sem categoria.
account_mapping_title: Atribuir suas contas
account_type_mapping_description: Atribuir todos os tipos de conta do seu arquivo importado aos
do %{product}
do %{product_name}
account_type_mapping_title: Atribuir seus tipos de conta
category_mapping_description: Atribua todas as categorias do seu arquivo importado às
categorias existentes no %{product}. Você também pode adicionar novas categorias ou deixá-las
categorias existentes no %{product_name}. Você também pode adicionar novas categorias ou deixá-las
sem categoria.
category_mapping_title: Atribuir suas categorias
tag_mapping_description: Atribua todas as tags do seu arquivo importado às
tags existentes no %{product}. Você também pode adicionar novas tags ou deixá-las
tags existentes no %{product_name}. Você também pode adicionar novas tags ou deixá-las
sem categoria.
tag_mapping_title: Atribuir suas tags
uploads:

View File

@@ -26,13 +26,13 @@ ro:
rows_label: Rânduri
unassigned_account: Trebuie să creezi un cont nou pentru rândurile neasignate?
show:
account_mapping_description: Asignează toate conturile din fișierul tău importat conturilor existente din Maybe. Poți adăuga și conturi noi sau le poți lăsa necategorizate.
account_mapping_description: Asignează toate conturile din fișierul tău importat conturilor existente din %{product_name}. Poți adăuga și conturi noi sau le poți lăsa necategorizate.
account_mapping_title: Asignează-ți conturile
account_type_mapping_description: Asignează toate tipurile de cont din fișierul tău importat celor din Maybe.
account_type_mapping_description: Asignează toate tipurile de cont din fișierul tău importat celor din %{product_name}.
account_type_mapping_title: Asignează-ți tipurile de cont
category_mapping_description: Asignează toate categoriile din fișierul tău importat categoriilor existente din Maybe. Poți adăuga și categorii noi sau le poți lăsa necategorizate.
category_mapping_description: Asignează toate categoriile din fișierul tău importat categoriilor existente din %{product_name}. Poți adăuga și categorii noi sau le poți lăsa necategorizate.
category_mapping_title: Asignează-ți categoriile
tag_mapping_description: Asignează toate etichetele din fișierul tău importat etichetelor existente din Maybe. Poți adăuga și etichete noi sau le poți lăsa necategorizate.
tag_mapping_description: Asignează toate etichetele din fișierul tău importat etichetelor existente din %{product_name}. Poți adăuga și etichete noi sau le poți lăsa necategorizate.
tag_mapping_title: Asignează-ți etichetele
uploads:
show:

View File

@@ -26,13 +26,13 @@ tr:
rows_label: Satırlar
unassigned_account: Atanmamış satırlar için yeni bir hesap oluşturmak ister misiniz?
show:
account_mapping_description: "İçe aktardığınız dosyadaki tüm hesapları %{product}'deki mevcut hesaplara eşleyin. Ayrıca yeni hesaplar ekleyebilir veya kategorize etmeden bırakabilirsiniz."
account_mapping_description: "İçe aktardığınız dosyadaki tüm hesapları %{product_name}'deki mevcut hesaplara eşleyin. Ayrıca yeni hesaplar ekleyebilir veya kategorize etmeden bırakabilirsiniz."
account_mapping_title: Hesaplarınızı eşleyin
account_type_mapping_description: "İçe aktardığınız dosyadaki tüm hesap türlerini %{product}'deki hesap türlerine eşleyin."
account_type_mapping_description: "İçe aktardığınız dosyadaki tüm hesap türlerini %{product_name}'deki hesap türlerine eşleyin."
account_type_mapping_title: Hesap türlerinizi eşleyin
category_mapping_description: "İçe aktardığınız dosyadaki tüm kategorileri %{product}'deki mevcut kategorilere eşleyin. Ayrıca yeni kategoriler ekleyebilir veya kategorize etmeden bırakabilirsiniz."
category_mapping_description: "İçe aktardığınız dosyadaki tüm kategorileri %{product_name}'deki mevcut kategorilere eşleyin. Ayrıca yeni kategoriler ekleyebilir veya kategorize etmeden bırakabilirsiniz."
category_mapping_title: Kategorilerinizi eşleyin
tag_mapping_description: "İçe aktardığınız dosyadaki tüm etiketleri %{product}'deki mevcut etiketlere eşleyin. Ayrıca yeni etiketler ekleyebilir veya kategorize etmeden bırakabilirsiniz."
tag_mapping_description: "İçe aktardığınız dosyadaki tüm etiketleri %{product_name}'deki mevcut etiketlere eşleyin. Ayrıca yeni etiketler ekleyebilir veya kategorize etmeden bırakabilirsiniz."
tag_mapping_title: Etiketlerinizi eşleyin
uploads:
show:

View File

@@ -34,13 +34,13 @@ zh-CN:
sure_mapping_label: "%{product_name} 中的 %{mapping}"
unassigned_account: 需要为未分配行创建新账户?
show:
account_mapping_description: 将导入文件中的所有账户分配到 %{product} 的现有账户。您也可以新建账户或保留为未分类。
account_mapping_description: 将导入文件中的所有账户分配到 %{product_name} 的现有账户。您也可以新建账户或保留为未分类。
account_mapping_title: 分配账户
account_type_mapping_description: 将导入文件中的所有账户类型分配到 %{product} 的账户类型
account_type_mapping_description: 将导入文件中的所有账户类型分配到 %{product_name} 的账户类型
account_type_mapping_title: 分配账户类型
category_mapping_description: 将导入文件中的所有分类分配到 %{product} 的现有分类。您也可以新建分类或保留为未分类。
category_mapping_description: 将导入文件中的所有分类分配到 %{product_name} 的现有分类。您也可以新建分类或保留为未分类。
category_mapping_title: 分配分类
tag_mapping_description: 将导入文件中的所有标签分配到 %{product} 的现有标签。您也可以新建标签或保留为未分类。
tag_mapping_description: 将导入文件中的所有标签分配到 %{product_name} 的现有标签。您也可以新建标签或保留为未分类。
tag_mapping_title: 分配标签
uploads:
show:

View File

@@ -34,13 +34,13 @@ zh-TW:
rows_label: 資料列
unassigned_account: 需要為未分配的資料列建立新帳戶嗎?
show:
account_mapping_description: 將匯入檔案中的帳戶對應到 Maybe 現有的帳戶。您也可以新增帳戶,或暫時不進行分類。
account_mapping_description: 將匯入檔案中的帳戶對應到 %{product_name} 現有的帳戶。您也可以新增帳戶,或暫時不進行分類。
account_mapping_title: 分配您的帳戶
account_type_mapping_description: 將匯入檔案中的帳戶類型對應到 Maybe 的帳戶類型
account_type_mapping_description: 將匯入檔案中的帳戶類型對應到 %{product_name} 的帳戶類型
account_type_mapping_title: 分配您的帳戶類型
category_mapping_description: 將匯入檔案中的類別對應到 Maybe 現有的類別。您也可以新增類別,或暫時不進行分類。
category_mapping_description: 將匯入檔案中的類別對應到 %{product_name} 現有的類別。您也可以新增類別,或暫時不進行分類。
category_mapping_title: 分配您的類別
tag_mapping_description: 將匯入檔案中的標籤對應到 Maybe 現有的標籤。您也可以新增標籤,或暫時不進行分類。
tag_mapping_description: 將匯入檔案中的標籤對應到 %{product_name} 現有的標籤。您也可以新增標籤,或暫時不進行分類。
tag_mapping_title: 分配您的標籤
uploads:
show:

View File

@@ -37,7 +37,7 @@ es:
never_expires: "Nunca expira"
permissions: "Permisos"
usage_instructions_title: "Cómo usar tu clave API"
usage_instructions: "Incluye tu clave API en el encabezado X-Api-Key al realizar solicitudes a la API de Maybe:"
usage_instructions: "Incluye tu clave API en el encabezado X-Api-Key al realizar solicitudes a la API de %{product_name}:"
regenerate_key: "Crear Nueva Clave"
revoke_key: "Revocar Clave"
revoke_confirmation: "¿Estás seguro de que deseas revocar esta clave API? Esta acción no se puede deshacer y deshabilitará inmediatamente todas las aplicaciones que usen esta clave."

View File

@@ -36,7 +36,7 @@ nb:
never_expires: "Utløper aldri"
permissions: "Tillatelser"
usage_instructions_title: "Hvordan bruke din API-nøkkel"
usage_instructions: "Inkluder din API-nøkkel i X-Api-Key-headeren når du gjør forespørsler til Maybe API-et:"
usage_instructions: "Inkluder din API-nøkkel i X-Api-Key-headeren når du gjør forespørsler til %{product_name} API-et:"
regenerate_key: "Opprett ny nøkkel"
revoke_key: "Tilbakekall nøkkel"
revoke_confirmation: "Er du sikker på at du vil tilbakekalle denne API-nøkkelen? Denne handlingen kan ikke angres og vil umiddelbart deaktivere alle applikasjoner som bruker denne nøkkelen."

View File

@@ -37,7 +37,7 @@ pl:
never_expires: Nigdy nie wygasa
permissions: Uprawnienia
usage_instructions_title: Jak używać klucza API
usage_instructions: 'Dołącz klucz API w nagłówku X-Api-Key podczas wysyłania żądań do API Maybe:'
usage_instructions: 'Dołącz klucz API w nagłówku X-Api-Key podczas wysyłania żądań do API %{product_name}:'
regenerate_key: Utwórz nowy klucz
revoke_key: Unieważnij klucz
revoke_confirmation: Czy na pewno chcesz unieważnić ten klucz API? Tej akcji nie można cofnąć, a wszystkie aplikacje używające tego klucza zostaną natychmiast wyłączone.

View File

@@ -36,7 +36,7 @@ tr:
never_expires: "Süresiz"
permissions: "Yetkiler"
usage_instructions_title: "API anahtarınızı nasıl kullanırsınız"
usage_instructions: "Maybe API'ye istek yaparken API anahtarınızı X-Api-Key başlığına ekleyin:"
usage_instructions: "%{product_name} API'ye istek yaparken API anahtarınızı X-Api-Key başlığına ekleyin:"
regenerate_key: "Yeni Anahtar Oluştur"
revoke_key: "Anahtarı İptal Et"
revoke_confirmation: "Bu API anahtarını iptal etmek istediğinizden emin misiniz? Bu işlem geri alınamaz ve bu anahtarı kullanan tüm uygulamalar hemen devre dışı kalır."

View File

@@ -37,7 +37,7 @@ zh-TW:
never_expires: "永不過期"
permissions: "權限範圍"
usage_instructions_title: "如何使用您的 API 金鑰"
usage_instructions: "在向 Maybe API 發送請求時,請在 X-Api-Key 標頭 (Header) 中包含您的 API 金鑰:"
usage_instructions: "在向 %{product_name} API 發送請求時,請在 X-Api-Key 標頭 (Header) 中包含您的 API 金鑰:"
regenerate_key: "建立新金鑰"
revoke_key: "撤銷金鑰"
revoke_confirmation: "您確定要撤銷此 API 金鑰嗎?此操作無法還原,且會立即停用所有使用此金鑰的應用程式。"

View File

@@ -450,13 +450,18 @@ Pipelock scans for prompt injection, DLP violations, and tool poisoning. The ext
**`NO_PROXY` behavior (Helm/Kubernetes only):** The Helm chart's env template sets `NO_PROXY` to include `.svc.cluster.local` and other internal domains. This means in-cluster agent URLs (like `http://agent.namespace.svc.cluster.local:18789`) bypass the forward proxy and go directly. If your agent is in-cluster, its traffic won't be forward-proxy scanned (but MCP callbacks from the agent are still scanned by the reverse proxy). Docker Compose deployments use a different `NO_PROXY` set; check your compose file for the exact values.
**`mcpToolPolicy` note:** The Helm chart's `pipelock.mcpToolPolicy.enabled` defaults to `true`. If you haven't defined any policy rules, disable it:
**`mcpToolPolicy` note:** The Helm chart's `pipelock.mcpToolPolicy.enabled` defaults to `false`. Pipelock rejects an enabled tool policy with no rules, so the chart ships it off by default. To turn it on, define at least one rule and set `enabled: true`:
```yaml
# Helm values
pipelock:
mcpToolPolicy:
enabled: false
enabled: true
action: warn
rules:
- name: example
toolPattern: "^shell$"
action: block
```
See the [Pipelock documentation](https://github.com/luckyPipewrench/pipelock) for tool policy configuration details.

View File

@@ -231,7 +231,7 @@ Pipelock provides:
- **Tool poisoning detection**: Prevents malicious tool call sequences
- **Policy enforcement**: Block or warn on suspicious patterns
See the [Pipelock documentation](pipelock.md) and the example configuration in `compose.example.pipelock.yml` for setup instructions.
See the [Pipelock documentation](pipelock.md) and the example configuration in `compose.example.ai.yml` for setup instructions.
### Network Security

View File

@@ -77,13 +77,13 @@ Enable Pipelock in your Helm values:
pipelock:
enabled: true
image:
tag: "2.2.0"
tag: "2.5.0"
mode: balanced
```
This creates a separate Deployment, Service, and ConfigMap. The chart auto-injects `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` into web and worker pods.
Recent pipelock releases add trusted domain allowlisting, MCP tool redirect profiles, enhanced tool poisoning detection (full JSON schema scanning), per-read kill switch preemption, signed action receipts, per-pattern DLP warn mode, and the `pipelock posture verify` / `pipelock session` CLI commands. See the [pipelock changelog](https://github.com/luckyPipewrench/pipelock/releases) for details.
Recent pipelock releases add the Audit Packet v0 schema and language-portable verifiers (Go/TypeScript/Rust), request-body prompt-injection blocking, SPIFFE-strict inbound mediation envelopes, scanner attribution on MCP block receipts, trusted domain allowlisting, MCP tool redirect profiles, enhanced tool poisoning detection, per-read kill switch preemption, signed action receipts, per-pattern DLP warn mode, learn-and-lock behavioural contracts, the wedge-detection health watchdog, and the `pipelock posture verify` / `pipelock session` / `pipelock doctor` CLI commands. See the [pipelock changelog](https://github.com/luckyPipewrench/pipelock/releases) for details.
### Exposing MCP to external agents (Kubernetes)
@@ -149,6 +149,7 @@ The `pipelock.example.yaml` file (Docker Compose) or ConfigMap (Helm) controls s
| `trusted_domains` | Allow internal services whose public DNS resolves to private IPs |
| `forward_proxy` | Outbound HTTPS scanning (tunnel timeouts, idle timeouts) |
| `dlp` | Data loss prevention (scan env vars, built-in patterns) |
| `request_body_scanning` | Scan outbound request bodies for prompt-injection and bodies/sensitive headers for DLP (pipelock 2.5+) |
| `response_scanning` | Scan LLM responses for prompt injection |
| `mcp_input_scanning` | Scan inbound MCP requests |
| `mcp_tool_scanning` | Validate tool calls, detect drift |
@@ -156,6 +157,7 @@ The `pipelock.example.yaml` file (Docker Compose) or ConfigMap (Helm) controls s
| `mcp_session_binding` | Pin tool inventory, detect manipulation |
| `tool_chain_detection` | Multi-step attack patterns |
| `websocket_proxy` | WebSocket frame scanning (disabled by default) |
| `health_watchdog` | Wedge-detection on subsystem heartbeats, returns 503 on stall (pipelock 2.4+) |
| `logging` | Output format (json/text), verbosity |
For the Helm chart, most sections are configurable via `values.yaml`. For additional sections not covered by structured values (session profiling, data budgets, kill switch, sandbox, reverse proxy, adaptive enforcement), use the `extraConfig` escape hatch:

View File

@@ -1,12 +1,16 @@
# Pipelock configuration for Docker Compose
# See https://github.com/luckyPipewrench/pipelock for full options.
#
# Recent additions: trusted_domains, redirect profiles, attack simulation,
# security scoring, process sandbox, enhanced tool poisoning detection, signed
# action receipts, per-pattern DLP warn mode, and the `pipelock posture verify`
# / `pipelock session` CLIs.
# Recent additions (2.5): Audit Packet v0 schema with Go/TypeScript/Rust
# verifiers, request-body prompt-injection blocking, SPIFFE-strict inbound
# mediation envelopes, scanner attribution on MCP block receipts, wedge-
# detection health watchdog, learn-and-lock behavioural contracts, trusted
# domains, redirect profiles, attack simulation, security scoring, process
# sandbox, signed action receipts, per-pattern DLP warn mode, and the
# `pipelock posture verify` / `pipelock session` / `pipelock doctor` CLIs.
# Run `pipelock simulate --config <file>` to test your config against 24 attack scenarios.
# Run `pipelock audit score --config <file>` for a security posture score (0-100).
# Run `pipelock doctor` to verify configured protections are actually enforceable.
version: 1
mode: balanced
@@ -72,3 +76,23 @@ tool_chain_detection:
action: warn
window_size: 20
max_gap: 3
# Request body scanning (pipelock 2.5+): detect prompt-injection payloads in
# outbound request bodies (JSON, form-encoded, raw text, WebSocket frames).
# In enforce mode, prompt-injection findings hard-block non-provider
# destinations even when action is "warn". Trusted provider hosts (OpenAI,
# Anthropic, etc.) remain exempt via the response_scanning exemption list.
request_body_scanning:
enabled: false
action: warn
max_body_bytes: 5242880
scan_headers: true
header_mode: sensitive
# Health watchdog (pipelock 2.4+): /health returns 503 when any subsystem
# heartbeat goes stale. Enabled by default; set expose_subsystems true to
# include a per-subsystem boolean map in /health responses.
health_watchdog:
enabled: true
interval_seconds: 2
expose_subsystems: false

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
require "test_helper"
require "openssl"
class EnableBankingItemsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@family = families(:dylan_family)
@item = @family.enable_banking_items.create!(
name: "Test Connection",
country_code: "DE",
application_id: "test_app_id",
client_certificate: OpenSSL::PKey::RSA.new(2048).to_pem
)
end
test "select_bank exposes ASPSP BIC in the searchable data attribute" do
Provider::EnableBanking.any_instance.stubs(:get_aspsps).returns(
aspsps: [
{
name: "ING-DiBa AG",
country: "DE",
bic: "INGDDEFF",
beta: false,
psu_types: [ "personal" ],
auth_methods: [ { approach: "REDIRECT" } ]
}
]
)
get select_bank_enable_banking_item_url(@item)
assert_response :success
haystack = @response.body[/data-bank-search="([^"]*)"/, 1]
assert haystack, "Expected list items to render a data-bank-search attribute the client filter reads from"
assert_includes haystack, "ingddeff",
"Expected the searchable data attribute to include the BIC so users can find banks by BIC code"
assert_includes haystack, "ing-diba ag",
"Expected the searchable data attribute to still include the bank name (existing name-search behavior)"
end
end

View File

@@ -95,6 +95,55 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
end
end
# Regression: issue #1824. The OpenAI form auto-submits on blur, so entering
# the URI base before the model fires a partial submit that fails validation.
# The re-rendered form must show the user's submitted URI base — not the
# still-blank saved value — so they can finish typing the model.
test "preserves submitted openai uri base in form when validation fails" do
with_self_hosting do
Setting.openai_uri_base = nil
Setting.openai_model = ""
patch settings_hosting_url, params: { setting: { openai_uri_base: "https://api.example.com/v1" } }
assert_response :unprocessable_entity
assert_select "input[name=?]", "setting[openai_uri_base]" do |inputs|
assert_equal "https://api.example.com/v1", inputs.first["value"]
end
end
ensure
Setting.openai_uri_base = nil
Setting.openai_model = nil
end
# PR #1862 review (jjmata): symmetric coverage for the model field. When the
# user changes the URI base and clears the model in the same auto-submit, the
# cross-field validation fails — the re-rendered model input must reflect the
# user's submitted (cleared) value, not silently revert to the saved model.
test "preserves submitted openai model in form when validation fails" do
with_self_hosting do
Setting.openai_uri_base = "https://saved.example.com/v1"
Setting.openai_model = "saved-model"
patch settings_hosting_url, params: { setting: {
openai_uri_base: "https://new.example.com/v1",
openai_model: ""
} }
assert_response :unprocessable_entity
assert_select "input[name=?]", "setting[openai_uri_base]" do |inputs|
assert_equal "https://new.example.com/v1", inputs.first["value"]
end
assert_select "input[name=?]", "setting[openai_model]" do |inputs|
assert_not_equal "saved-model", inputs.first["value"].to_s,
"model field must reflect the submitted (cleared) value, not the saved model"
end
end
ensure
Setting.openai_uri_base = nil
Setting.openai_model = nil
end
test "can update openai model alone when self hosting is enabled" do
with_self_hosting do
patch settings_hosting_url, params: { setting: { openai_model: "gpt-4" } }

View File

@@ -382,4 +382,41 @@ class AccountTest < ActiveSupport::TestCase
assert_equal [ provider_holding.id, second_provider_holding.id ].sort, account.current_holdings.pluck(:id).sort
assert_equal %w[CHF EUR], account.current_holdings.pluck(:currency).sort
end
test "on account destroyed cascade transfer destroyed" do
outflow_account = @family.accounts.create!({
owner: @admin,
name: "test_account_outflow",
balance: 100,
currency: "USD",
accountable_type: "Depository",
accountable_attributes: {}
})
inflow_account = @family.accounts.create!({
owner: @admin,
name: "test_account_inflow",
balance: 100,
currency: "USD",
accountable_type: "Depository",
accountable_attributes: {}
})
transfer = create_transfer(
from_account: outflow_account,
to_account: inflow_account,
amount: 50
)
outflow_transaction = transfer.outflow_transaction
outflow_transaction.reload
assert_equal "funds_movement", outflow_transaction.kind
inflow_account.destroy!
assert_raises(ActiveRecord::RecordNotFound) { transfer.reload }
outflow_transaction.reload
assert_equal "standard", outflow_transaction.kind
end
end

View File

@@ -213,6 +213,163 @@ class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase
assert_not_nil @account.balances.find_by(date: Date.new(2026, 5, 8), currency: "CHF")
end
test "computes net_market_flows from equity delta minus trade flows" do
# Day 1: total=3000, cash=500, non_cash=2500
# Day 2: total=3200, cash=500, non_cash=2700 (Δnon_cash=200)
# Buy trade on Day 2: CHF 150 (same currency as account, no FX)
# nmf = 200 - 150 = 50
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3200.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00)
security = Security.create!(ticker: "TEST", name: "Test Stock")
@account.entries.create!(
name: "Buy 100 TEST",
date: Date.new(2026, 5, 8),
amount: 150.00,
currency: "CHF",
entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "CHF")
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("0"), day1.net_market_flows
assert_equal BigDecimal("50"), day2.net_market_flows
# Virtual column must still resolve to IBKR's equity total minus cash
assert_equal BigDecimal("2500.00"), day1.end_non_cash_balance
assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance
end
test "applies fx_rate_to_base when trade currency differs from account currency" do
# Trade in EUR with fx_rate_to_base=1.1 → CHF 165, not CHF 150
# Day 1: non_cash=2500, Day 2: non_cash=2700 (Δ=200)
# nmf = 200 - 165 = 35
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3200.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00)
security = Security.create!(ticker: "TEST2", name: "Test Stock EUR")
trade = Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR")
trade.exchange_rate = 1.1
@account.entries.create!(
name: "Buy 100 TEST2",
date: Date.new(2026, 5, 8),
amount: 150.00,
currency: "EUR",
entryable: trade
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_in_delta 35.0, day2.net_market_flows.to_f, 0.01
assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance
end
test "excludes balance row from upsert when Money::ConversionError prevents FX conversion" do
# EUR trade with no exchange_rate stored → custom_rate=nil → ConversionError raised.
# The affected date is excluded from the upsert entirely so net_market_flows is not
# silently wrong (the trade's value would otherwise flow into market appreciation).
# The seeded day2 balance is intentionally different from IBKR's total (3150 vs 3200)
# so we can assert the row was not overwritten by sync.
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3200.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3150.00, cash_balance: 500.00)
security = Security.create!(ticker: "NORATE", name: "No Rate EUR Stock")
@account.entries.create!(
name: "Buy 100 NORATE",
date: Date.new(2026, 5, 8),
amount: 150.00,
currency: "EUR",
entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR")
)
Money.any_instance.stubs(:exchange_to).raises(
Money::ConversionError.new(from_currency: "EUR", to_currency: "CHF", date: Date.new(2026, 5, 8))
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
# Day 1 is unaffected — still synced normally
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
assert_equal BigDecimal("3000.00"), day1.balance
# Day 2 was excluded from the upsert — seeded values are preserved, not overwritten
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("3150.00"), day2.balance # seeded, not IBKR's 3200
assert_equal BigDecimal("0"), day2.net_market_flows # seeded, not recomputed
end
test "net_market_flows equals full non_cash delta when account has no trades" do
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "3300.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 3300.00, cash_balance: 500.00)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF")
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("0"), day1.net_market_flows
assert_equal BigDecimal("300"), day2.net_market_flows
assert_equal BigDecimal("2800.00"), day2.end_non_cash_balance
end
test "sell trades reduce net_buy_sell so market loss is isolated in net_market_flows" do
# Day 1: total=3000, cash=500, non_cash=2500
# Day 2: total=2700, cash=700, non_cash=2000 (Δnon_cash=-500)
# Sell 100 at CHF 1.50: entry.amount=-150 (negative = proceeds received)
# net_buy_sell=-150; nmf = -500 - (-150) = -350 (market caused -350 loss)
@ibkr_account.update!(
raw_equity_summary_payload: [
{ report_date: "2026-05-07", total: "3000.00" },
{ report_date: "2026-05-08", total: "2700.00" }
]
)
seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00)
seed_balance(date: Date.new(2026, 5, 8), balance: 2700.00, cash_balance: 700.00)
security = Security.create!(ticker: "SELL_TEST", name: "Sell Test Stock")
@account.entries.create!(
name: "Sell 100 SELL_TEST",
date: Date.new(2026, 5, 8),
amount: -150.00,
currency: "CHF",
entryable: Trade.new(security: security, qty: -100, price: 1.5, currency: "CHF")
)
IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync!
day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF")
assert_equal BigDecimal("-350"), day2.net_market_flows
assert_equal BigDecimal("2000.00"), day2.end_non_cash_balance
end
test "writes balance row with zero total for fully liquidated dates" do
@ibkr_account.update!(
raw_equity_summary_payload: [