Merge main: resolve grid layout conflicts, add missing privacy-sensitive coverage

Resolve merge conflicts in investment summary/performance views where main's
grid layout refactoring conflicted with privacy-sensitive class additions.
Also add privacy-sensitive to transaction list amounts, transaction detail
header, and sankey cashflow chart containers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sokiee
2026-03-22 10:46:52 +01:00
398 changed files with 18005 additions and 1353 deletions

View File

@@ -25,6 +25,21 @@ OPENAI_ACCESS_TOKEN=
OPENAI_MODEL=
OPENAI_URI_BASE=
# Optional: External AI Assistant — delegates chat to a remote AI agent
# instead of calling LLMs directly. The agent calls back to Sure's /mcp endpoint.
# See docs/hosting/ai.md for full details.
# ASSISTANT_TYPE=external
# EXTERNAL_ASSISTANT_URL=https://your-agent-host/v1/chat/completions
# EXTERNAL_ASSISTANT_TOKEN=your-api-token
# EXTERNAL_ASSISTANT_AGENT_ID=main
# EXTERNAL_ASSISTANT_SESSION_KEY=agent:main:main
# EXTERNAL_ASSISTANT_ALLOWED_EMAILS=user@example.com
# Optional: MCP server endpoint — enables /mcp for external AI assistants.
# Both values are required. MCP_USER_EMAIL must match an existing user's email.
# MCP_API_TOKEN=your-random-bearer-token
# MCP_USER_EMAIL=user@example.com
# Optional: Langfuse config
LANGFUSE_HOST=https://cloud.langfuse.com
LANGFUSE_PUBLIC_KEY=

27
.github/workflows/pipelock.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Pipelock Security Scan
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Pipelock Scan
uses: luckyPipewrench/pipelock@v1
with:
scan-diff: 'true'
fail-on-findings: 'true'
test-vectors: 'false'
exclude-paths: |
config/locales/views/reports/
docs/hosting/ai.md

View File

@@ -40,3 +40,12 @@ To get setup for local development, you have two options:
7. Before requesting a review, please make sure that all [Github Checks](https://docs.github.com/en/rest/checks?apiVersion=2022-11-28) have passed and your branch is up-to-date with the `main` branch. After doing so, request a review and wait for a maintainer's approval.
All PRs should target the `main` branch.
### Automated Security Scanning
Every pull request to the `main` branch automatically runs a Pipelock security scan. This scan analyzes your PR diff for:
- Leaked secrets (API keys, tokens, credentials)
- Agent security risks (misconfigurations, exposed credentials, missing controls)
The scan runs as part of the CI pipeline and typically completes in ~30 seconds. If security issues are found, the CI check will fail. You don't need to configure anything—the security scanning is automatic and zero-configuration.

View File

@@ -282,7 +282,7 @@ GEM
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.18.1)
json (2.19.2)
json-jwt (1.16.7)
activesupport (>= 4.2)
aes_key_wrap
@@ -441,7 +441,7 @@ GEM
ostruct (0.6.2)
pagy (9.3.5)
parallel (1.27.0)
parser (3.3.8.0)
parser (3.3.10.2)
ast (~> 2.4.1)
racc
pdf-reader (2.15.1)

View File

@@ -57,7 +57,7 @@ To stay compliant and avoid trademark issues:
With data-heavy apps, inevitably, there are performance issues. We've set up a public dashboard showing the problematic requests seen on the demo site, along with the stacktraces to help debug them.
https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints
[https://www.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints](https://oss.skylight.io/app/applications/s6PEZSKwcklL/recent/6h/endpoints)
Any contributions that help improve performance are very much welcome.

View File

@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<g filter="url(#filter0_i_claw_d)">
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_claw_d)"/>
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
</g>
<g filter="url(#filter1_ii_claw_d)">
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_claw_d)"/>
</g>
<!-- Lobster/claw icon -->
<!-- Left claw -->
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="#141414"/>
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="url(#paint2_linear_claw_d)"/>
<!-- Right claw -->
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="#141414"/>
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="url(#paint2_linear_claw_d)"/>
<!-- Left arm -->
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Right arm -->
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Body -->
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="#141414"/>
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="url(#paint2_linear_claw_d)"/>
<!-- Eyes -->
<circle cx="14" cy="16.5" r="1" fill="white"/>
<circle cx="18" cy="16.5" r="1" fill="white"/>
<circle cx="14" cy="16.5" r="0.5" fill="#141414"/>
<circle cx="18" cy="16.5" r="0.5" fill="#141414"/>
<!-- Antennae -->
<path d="M14 13L12.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M14 13L12.5 9.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="url(#paint2_linear_claw_d)" stroke-width="1" stroke-linecap="round"/>
<defs>
<filter id="filter0_i_claw_d" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.49869"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw_d"/>
</filter>
<filter id="filter1_ii_claw_d" x="1.75" y="0.75" width="28.5" height="30.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.92 0 0 0 0 0.16 0 0 0 0 0.16 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw_d"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.45 0 0 0 0 0.2 0 0 0 0.15 0"/>
<feBlend mode="normal" in2="effect1_innerShadow_claw_d" result="effect2_innerShadow_claw_d"/>
</filter>
<linearGradient id="paint0_linear_claw_d" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
<linearGradient id="paint1_linear_claw_d" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
<stop stop-color="#171717"/>
<stop offset="0.3" stop-color="#0B0B0B"/>
</linearGradient>
<linearGradient id="paint2_linear_claw_d" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<g filter="url(#filter0_i_claw)">
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_claw)"/>
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
</g>
<g filter="url(#filter1_ii_claw)">
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_claw)"/>
</g>
<!-- Lobster/claw icon -->
<!-- Left claw -->
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="#141414"/>
<path d="M9.5 11.5C8.2 10 6 9.8 5.5 11.5C5 13.2 6.5 14.5 8 14.5C8.8 14.5 9.3 14 9.5 13.5" fill="url(#paint2_linear_claw)"/>
<!-- Right claw -->
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="#141414"/>
<path d="M22.5 11.5C23.8 10 26 9.8 26.5 11.5C27 13.2 25.5 14.5 24 14.5C23.2 14.5 22.7 14 22.5 13.5" fill="url(#paint2_linear_claw)"/>
<!-- Left arm -->
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M9.5 13C10 14.5 11 15.5 12 16.5" stroke="url(#paint2_linear_claw)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Right arm -->
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="#141414" stroke-width="1.5" stroke-linecap="round"/>
<path d="M22.5 13C22 14.5 21 15.5 20 16.5" stroke="url(#paint2_linear_claw)" stroke-width="1.5" stroke-linecap="round"/>
<!-- Body -->
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="#141414"/>
<path d="M16 13C13 13 11 15 11 17.5C11 20 12.5 22 14.5 23L13.5 25.5C13.3 26 13.6 26.5 14 26.5C14.4 26.5 14.7 26.2 14.8 25.8L15.5 23.8C15.7 23.8 16 23.9 16 23.9C16 23.9 16.3 23.8 16.5 23.8L17.2 25.8C17.3 26.2 17.6 26.5 18 26.5C18.4 26.5 18.7 26 18.5 25.5L17.5 23C19.5 22 21 20 21 17.5C21 15 19 13 16 13Z" fill="url(#paint2_linear_claw)"/>
<!-- Eyes -->
<circle cx="14" cy="16.5" r="1" fill="white"/>
<circle cx="18" cy="16.5" r="1" fill="white"/>
<circle cx="14" cy="16.5" r="0.5" fill="#141414"/>
<circle cx="18" cy="16.5" r="0.5" fill="#141414"/>
<!-- Antennae -->
<path d="M14 13L12.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M14 13L12.5 9.5" stroke="url(#paint2_linear_claw)" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="#141414" stroke-width="1" stroke-linecap="round"/>
<path d="M18 13L19.5 9.5" stroke="url(#paint2_linear_claw)" stroke-width="1" stroke-linecap="round"/>
<defs>
<filter id="filter0_i_claw" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.49869"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw"/>
</filter>
<filter id="filter1_ii_claw" x="1.75" y="0.861111" width="28.5" height="30.2778" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.888889"/>
<feGaussianBlur stdDeviation="0.888889"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.92 0 0 0 0 0.16 0 0 0 0 0.16 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_claw"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.888889"/>
<feGaussianBlur stdDeviation="0.888889"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 0.45 0 0 0 0 0.2 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_innerShadow_claw" result="effect2_innerShadow_claw"/>
</filter>
<linearGradient id="paint0_linear_claw" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
<linearGradient id="paint1_linear_claw" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="0.3" stop-color="#F7F7F7"/>
</linearGradient>
<linearGradient id="paint2_linear_claw" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF6B35"/>
<stop offset="0.35" stop-color="#EF0011"/>
<stop offset="0.7" stop-color="#C50010"/>
<stop offset="1" stop-color="#8B0000"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -368,12 +368,14 @@
text-overflow: clip;
}
select.form-field__input {
select.form-field__input,
button.form-field__input {
@apply pr-10 appearance-none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right -0.15rem center;
background-repeat: no-repeat;
background-size: 1.25rem 1.25rem;
text-align: left;
}
.form-field__radio {

View File

@@ -80,7 +80,7 @@ class DS::Buttonish < DesignSystemComponent
merged_base_classes,
full_width ? "w-full justify-center" : nil,
container_size_classes,
size_data.dig(:text_classes),
icon_only? ? nil : size_data.dig(:text_classes),
variant_data.dig(:container_classes)
)
end
@@ -108,7 +108,7 @@ class DS::Buttonish < DesignSystemComponent
end
def icon_only?
variant.in?([ :icon, :icon_inverse ])
variant.in?([ :icon, :icon_inverse ]) || (icon.present? && text.blank?)
end
private

View File

@@ -1,4 +1,4 @@
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, testid: testid } do %>
<%= tag.div data: { controller: "DS--menu", DS__menu_placement_value: placement, DS__menu_offset_value: offset, DS__menu_mobile_fullwidth_value: mobile_fullwidth, testid: testid } do %>
<% if variant == :icon %>
<%= render DS::Button.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { DS__menu_target: "button" }) %>
<% elsif variant == :button %>
@@ -12,7 +12,7 @@
<% end %>
<div data-DS--menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
<%= tag.div class: "mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg", style: ("max-width: #{max_width}" if max_width) do %>
<%= header %>
<%= tag.div class: class_names("py-1" => !no_padding) do %>
@@ -22,6 +22,6 @@
<%= custom_content %>
<% end %>
</div>
<% end %>
</div>
<% end %>

View File

@@ -1,7 +1,7 @@
# frozen_string_literal: true
class DS::Menu < DesignSystemComponent
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid
attr_reader :variant, :avatar_url, :initials, :placement, :offset, :icon_vertical, :no_padding, :testid, :mobile_fullwidth, :max_width
renders_one :button, ->(**button_options, &block) do
options_with_target = button_options.merge(data: { DS__menu_target: "button" })
@@ -23,7 +23,7 @@ class DS::Menu < DesignSystemComponent
VARIANTS = %i[icon button avatar].freeze
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
def initialize(variant: "icon", avatar_url: nil, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil, mobile_fullwidth: true, max_width: nil)
@variant = variant.to_sym
@avatar_url = avatar_url
@initials = initials
@@ -32,6 +32,8 @@ class DS::Menu < DesignSystemComponent
@icon_vertical = icon_vertical
@no_padding = no_padding
@testid = testid
@mobile_fullwidth = mobile_fullwidth
@max_width = max_width
raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
end

View File

@@ -16,6 +16,7 @@ export default class extends Controller {
show: Boolean,
placement: { type: String, default: "bottom-end" },
offset: { type: Number, default: 6 },
mobileFullwidth: { type: Boolean, default: true },
};
connect() {
@@ -105,13 +106,14 @@ export default class extends Controller {
if (!this.buttonTarget || !this.contentTarget) return;
const isSmallScreen = !window.matchMedia("(min-width: 768px)").matches;
const useMobileFullwidth = isSmallScreen && this.mobileFullwidthValue;
computePosition(this.buttonTarget, this.contentTarget, {
placement: isSmallScreen ? "bottom" : this.placementValue,
placement: useMobileFullwidth ? "bottom" : this.placementValue,
middleware: [offset(this.offsetValue), shift({ padding: 5 })],
strategy: "fixed",
}).then(({ x, y }) => {
if (isSmallScreen) {
if (useMobileFullwidth) {
Object.assign(this.contentTarget.style, {
position: "fixed",
left: "0px",

View File

@@ -0,0 +1,94 @@
<%# locals: form:, method:, collection:, options: {} %>
<div class="relative" data-controller="select <%= "list-filter" if searchable %> form-dropdown" data-action="dropdown:select->form-dropdown#onSelect">
<div class="form-field <%= options[:container_class] %>">
<div class="form-field__body">
<%= form.label method, options[:label], class: "form-field__label" if options[:label].present? %>
<%= form.hidden_field method,
value: @selected_value,
data: {
"form-dropdown-target": "input",
"auto-submit-target": "auto"
} %>
<button type="button"
class="form-field__input w-full"
data-select-target="button"
data-action="click->select#toggle"
aria-haspopup="listbox"
aria-expanded="<%= @selected_value.present? ? "true" : "false" %>"
aria-labelledby="<%= "#{method}_label" %>">
<%= selected_item&.dig(:label) || @placeholder %>
</button>
</div>
</div>
<div class="absolute z-50 p-1.5 w-full min-w-32 rounded-lg shadow-lg shadow-border-xs bg-container mt-1.5 transition duration-150 ease-out -translate-y-1 opacity-0 hidden" data-select-target="menu">
<% if searchable %>
<div class="relative flex items-center bg-container border border-secondary rounded-lg mb-1">
<input type="search"
placeholder="<%= t("helpers.select.search_placeholder") %>"
autocomplete="off"
class="bg-container text-sm placeholder:text-secondary font-normal h-10 pl-10 w-full border-none rounded-lg focus:outline-hidden focus:ring-0"
data-list-filter-target="input"
data-action="list-filter#filter">
<%= helpers.icon("search", class: "absolute inset-0 ml-2 transform top-1/2 -translate-y-1/2") %>
</div>
<% end %>
<div data-list-filter-target="list" data-select-target="content" class="flex flex-col gap-0.5 max-h-64 overflow-auto"
role="listbox" tabindex="-1">
<% items.each do |item| %>
<% is_selected = item[:value] == selected_value %>
<% obj = item[:object] %>
<div class="filterable-item text-sm cursor-pointer flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-container-inset-hover <%= "bg-container-inset" if is_selected %>"
role="option"
tabindex="0"
aria-selected="<%= is_selected %>"
data-action="click->select#select"
data-value="<%= item[:value] %>"
data-filter-name="<%= item[:label] %>">
<span class="check-icon <%= "hidden" unless is_selected %>">
<%= helpers.icon("check") %>
</span>
<% case variant %>
<% when :simple %>
<%= item[:label] %>
<% when :logo %>
<% unless item[:value].nil? %>
<% if logo_for(item) %>
<%= image_tag logo_for(item),
class: "w-6 h-6 rounded-full border border-secondary",
loading: "lazy" %>
<% else %>
<%= render DS::FilledIcon.new(
variant: :text,
text: item[:label],
size: "sm",
rounded: true
) %>
<% end %>
<% end %>
<%= item[:label] %>
<% when :badge %>
<% hex_color = color_for(item) %>
<span class="flex items-center gap-2 text-sm font-medium rounded-full px-3 py-1 border truncate"
style="
background-color: color-mix(in oklab, <%= hex_color %> 10%, transparent);
border-color: color-mix(in oklab, <%= hex_color %> 20%, transparent);
color: <%= hex_color %>;">
<% if icon_for(item) %>
<%= helpers.icon icon_for(item), size: "sm", color: "current" %>
<% else %>
<span class="size-1.5 rounded-full" style="background-color: <%= hex_color %>;"></span>
<% end %>
<%= item[:label] %>
</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -0,0 +1,83 @@
module DS
class Select < ViewComponent::Base
attr_reader :form, :method, :items, :selected_value, :placeholder, :variant, :searchable, :options
VARIANTS = %i[simple logo badge].freeze
HEX_COLOR_REGEX = /\A#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\z/
RGB_COLOR_REGEX = /\Argb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)\z/
DEFAULT_COLOR = "#737373"
def initialize(form:, method:, items:, selected: nil, placeholder: I18n.t("helpers.select.default_label"), variant: :simple, include_blank: nil, searchable: false, **options)
@form = form
@method = method
@placeholder = placeholder
@variant = variant
@searchable = searchable
@options = options
normalized_items = normalize_items(items)
if include_blank
normalized_items.unshift({
value: nil,
label: include_blank,
object: nil
})
end
@items = normalized_items
@selected_value = selected
end
def selected_item
items.find { |item| item[:value] == selected_value }
end
# Returns the color for a given item (used in :badge variant)
def color_for(item)
obj = item[:object]
color = obj&.respond_to?(:color) ? obj.color : DEFAULT_COLOR
return DEFAULT_COLOR unless color.is_a?(String)
if color.match?(HEX_COLOR_REGEX) || color.match?(RGB_COLOR_REGEX)
color
else
DEFAULT_COLOR
end
end
# Returns the lucide_icon name for a given item (used in :badge variant)
def icon_for(item)
obj = item[:object]
obj&.respond_to?(:lucide_icon) ? obj.lucide_icon : nil
end
# Returns true if the item has a logo (used in :logo variant)
def logo_for(item)
obj = item[:object]
obj&.respond_to?(:logo_url) && obj.logo_url.present? ? Setting.transform_brand_fetch_url(obj.logo_url) : nil
end
private
def normalize_items(collection)
collection.map do |item|
case item
when Hash
{
value: item[:value],
label: item[:label],
object: item[:object]
}
else
{
value: item.id,
label: item.name,
object: item
}
end
end
end
end
end

View File

@@ -1,5 +1,5 @@
class AccountsController < ApplicationController
before_action :set_account, only: %i[sync sparkline toggle_active show destroy unlink confirm_unlink select_provider]
before_action :set_account, only: %i[sync sparkline toggle_active set_default remove_default show destroy unlink confirm_unlink select_provider]
include Periodable
def index
@@ -42,7 +42,11 @@ class AccountsController < ApplicationController
@q = params.fetch(:q, {}).permit(:search, status: [])
entries = @account.entries.where(excluded: false).search(@q).reverse_chronological
@pagy, @entries = pagy(entries, limit: safe_per_page)
@pagy, @entries = pagy(
entries,
limit: safe_per_page,
params: request.query_parameters.except("tab").merge("tab" => "activity")
)
@activity_feed_data = Account::ActivityFeedData.new(@account, @entries)
end
@@ -85,6 +89,21 @@ class AccountsController < ApplicationController
redirect_to accounts_path
end
def set_default
unless @account.eligible_for_transaction_default?
redirect_to accounts_path, alert: t("accounts.set_default.depository_only")
return
end
Current.user.update!(default_account: @account)
redirect_to accounts_path
end
def remove_default
Current.user.update!(default_account: nil)
redirect_to accounts_path
end
def destroy
if @account.linked?
redirect_to account_path(@account), alert: t("accounts.destroy.cannot_delete_linked")

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
module Admin
class InvitationsController < Admin::BaseController
def destroy
invitation = Invitation.find(params[:id])
invitation.destroy!
redirect_to admin_users_path, notice: t(".success")
end
def destroy_all
family = Family.find(params[:id])
family.invitations.pending.destroy_all
redirect_to admin_users_path, notice: t(".success")
end
end
end

View File

@@ -13,7 +13,7 @@ module Admin
scope = scope.where(role: params[:role]) if params[:role].present?
scope = apply_trial_filter(scope) if params[:trial_status].present?
@users = scope.order(
users = scope.order(
Arel.sql(
"CASE " \
"WHEN subscriptions.status = 'trialing' THEN 0 " \
@@ -23,14 +23,22 @@ module Admin
)
)
family_ids = @users.map(&:family_id).uniq
family_ids = users.map(&:family_id).uniq
@accounts_count_by_family = Account.where(family_id: family_ids).group(:family_id).count
@entries_count_by_family = Entry.joins(:account).where(accounts: { family_id: family_ids }).group("accounts.family_id").count
user_ids = @users.map(&:id).uniq
user_ids = users.map(&:id).uniq
@last_login_by_user = Session.where(user_id: user_ids).group(:user_id).maximum(:created_at)
@sessions_count_by_user = Session.where(user_id: user_ids).group(:user_id).count
@families_with_users = users.group_by(&:family).sort_by do |family, _users|
-(@entries_count_by_family[family.id] || 0)
end
@invitations_by_family = Invitation.pending
.where(family_id: family_ids)
.group_by(&:family_id)
@trials_expiring_in_7_days = Subscription
.where(status: :trialing)
.where(trial_ends_at: Time.current..7.days.from_now)

View File

@@ -140,6 +140,92 @@ module Api
}
end
def sso_link
linking_code = params[:linking_code]
cached = validate_linking_code(linking_code)
return unless cached
user = User.authenticate_by(email: params[:email], password: params[:password])
unless user
render json: { error: "Invalid email or password" }, status: :unauthorized
return
end
if user.otp_required?
render json: { error: "MFA users should sign in with email and password", mfa_required: true }, status: :unauthorized
return
end
# Atomically claim the code before creating the identity
return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code)
OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user)
SsoAuditLog.log_link!(
user: user,
provider: cached[:provider],
request: request
)
issue_mobile_tokens(user, cached[:device_info])
end
def sso_create_account
linking_code = params[:linking_code]
cached = validate_linking_code(linking_code)
return unless cached
email = cached[:email]
# Check for a pending invitation for this email
invitation = Invitation.pending.find_by(email: email)
unless invitation.present? || cached[:allow_account_creation]
render json: { error: "SSO account creation is disabled. Please contact an administrator." }, status: :forbidden
return
end
# Atomically claim the code before creating the user
return render json: { error: "Linking code is invalid or expired" }, status: :unauthorized unless consume_linking_code!(linking_code)
user = User.new(
email: email,
first_name: params[:first_name].presence || cached[:first_name],
last_name: params[:last_name].presence || cached[:last_name],
skip_password_validation: true
)
if invitation.present?
# Accept the pending invitation: join the existing family
user.family_id = invitation.family_id
user.role = invitation.role
else
user.family = Family.new
provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == cached[:provider] }
provider_default_role = provider_config&.dig(:settings, :default_role)
user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin)
end
if user.save
# Mark invitation as accepted if one was used
invitation&.update!(accepted_at: Time.current)
OidcIdentity.create_from_omniauth(build_omniauth_hash(cached), user)
SsoAuditLog.log_jit_account_created!(
user: user,
provider: cached[:provider],
request: request
)
issue_mobile_tokens(user, cached[:device_info])
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
def enable_ai
user = current_resource_owner
@@ -248,6 +334,48 @@ module Api
}
end
def build_omniauth_hash(cached)
OpenStruct.new(
provider: cached[:provider],
uid: cached[:uid],
info: OpenStruct.new(cached.slice(:email, :name, :first_name, :last_name)),
extra: OpenStruct.new(raw_info: OpenStruct.new(iss: cached[:issuer]))
)
end
def validate_linking_code(linking_code)
if linking_code.blank?
render json: { error: "Linking code is required" }, status: :bad_request
return nil
end
cache_key = "mobile_sso_link:#{linking_code}"
cached = Rails.cache.read(cache_key)
unless cached.present?
render json: { error: "Linking code is invalid or expired" }, status: :unauthorized
return nil
end
cached
end
# Atomically deletes the linking code from cache.
# Returns true only for the first caller; subsequent callers get false.
def consume_linking_code!(linking_code)
Rails.cache.delete("mobile_sso_link:#{linking_code}")
end
def issue_mobile_tokens(user, device_info)
device_info = device_info.symbolize_keys if device_info.respond_to?(:symbolize_keys)
device = MobileDevice.upsert_device!(user, device_info)
token_response = device.issue_token!
render json: token_response.merge(user: mobile_user_payload(user))
rescue ActiveRecord::RecordInvalid => e
render json: { error: "Failed to register device: #{e.message}" }, status: :unprocessable_entity
end
def ensure_write_scope
authorize_scope!(:write)
end

View File

@@ -73,6 +73,11 @@ class Api::V1::BaseController < ApplicationController
render_json({ error: "unauthorized", message: "Access token is invalid - user not found" }, status: :unauthorized)
return false
end
unless @current_user.active?
render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized)
return false
end
else
Rails.logger.warn "API OAuth Token Invalid: Access token missing resource_owner_id"
render_json({ error: "unauthorized", message: "Access token is invalid - missing resource owner" }, status: :unauthorized)
@@ -96,6 +101,11 @@ class Api::V1::BaseController < ApplicationController
return false unless @api_key && @api_key.active?
@current_user = @api_key.user
unless @current_user.active?
render_json({ error: "unauthorized", message: "Account has been deactivated" }, status: :unauthorized)
return false
end
@api_key.update_last_used!
@authentication_method = :api_key
@rate_limiter = ApiRateLimiter.limit(@api_key)

View File

@@ -62,11 +62,6 @@ class Api::V1::CategoriesController < Api::V1::BaseController
end
def apply_filters(query)
# Filter by classification (income/expense)
if params[:classification].present?
query = query.where(classification: params[:classification])
end
# Filter for root categories only (no parent)
if params[:roots_only].present? && ActiveModel::Type::Boolean.new.cast(params[:roots_only])
query = query.roots

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
class Api::V1::UsersController < Api::V1::BaseController
before_action :ensure_write_scope
before_action :ensure_admin, only: :reset
def reset
FamilyResetJob.perform_later(Current.family)
render json: { message: "Account reset has been initiated" }
end
def destroy
user = current_resource_owner
if user.deactivate
Current.session&.destroy
render json: { message: "Account has been deleted" }
else
render json: { error: "Failed to delete account", details: user.errors.full_messages }, status: :unprocessable_entity
end
end
private
def ensure_write_scope
authorize_scope!(:write)
end
def ensure_admin
return true if current_resource_owner&.admin?
render_json({ error: "forbidden", message: I18n.t("users.reset.unauthorized") }, status: :forbidden)
false
end
end

View File

@@ -0,0 +1,13 @@
class ArchivedExportsController < ApplicationController
skip_authentication
def show
export = ArchivedExport.find_by_download_token!(params[:token])
if export.downloadable?
redirect_to rails_blob_path(export.export_file, disposition: "attachment")
else
head :gone
end
end
end

View File

@@ -1,11 +1,12 @@
class BudgetsController < ApplicationController
before_action :set_budget, only: %i[show edit update]
before_action :set_budget, only: %i[show edit update copy_previous]
def index
redirect_to_current_month_budget
end
def show
@source_budget = @budget.most_recent_initialized_budget unless @budget.initialized?
end
def edit
@@ -17,6 +18,22 @@ class BudgetsController < ApplicationController
redirect_to budget_budget_categories_path(@budget)
end
def copy_previous
if @budget.initialized?
redirect_to budget_path(@budget), alert: t("budgets.copy_previous.already_initialized")
return
end
source_budget = @budget.most_recent_initialized_budget
if source_budget
@budget.copy_from!(source_budget)
redirect_to budget_budget_categories_path(@budget), notice: t("budgets.copy_previous.success", source_name: source_budget.name)
else
redirect_to budget_path(@budget), alert: t("budgets.copy_previous.no_source")
end
end
def picker
render partial: "budgets/picker", locals: {
family: Current.family,

View File

@@ -87,6 +87,6 @@ class CategoriesController < ApplicationController
end
def category_params
params.require(:category).permit(:name, :color, :parent_id, :classification, :lucide_icon)
params.require(:category).permit(:name, :color, :parent_id, :lucide_icon)
end
end

View File

@@ -34,7 +34,15 @@ module AccountableResource
end
def create
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
opening_balance_date = begin
account_params[:opening_balance_date].presence&.to_date
rescue Date::Error
nil
end || (Time.zone.today - 2.years)
@account = Current.family.accounts.create_and_sync(
account_params.except(:return_to, :opening_balance_date),
opening_balance_date: opening_balance_date
)
@account.lock_saved_attributes!
redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize)
@@ -52,7 +60,7 @@ module AccountableResource
end
# Update remaining account attributes
update_params = account_params.except(:return_to, :balance, :currency)
update_params = account_params.except(:return_to, :balance, :currency, :opening_balance_date)
unless @account.update(update_params)
@error_message = @account.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
@@ -85,6 +93,7 @@ module AccountableResource
def account_params
params.require(:account).permit(
:name, :balance, :subtype, :currency, :accountable_type, :return_to,
:opening_balance_date,
:institution_name, :institution_domain, :notes,
accountable_attributes: self.class.permitted_accountable_attributes
)

View File

@@ -9,7 +9,7 @@ module Invitable
def invite_code_required?
return false if @invitation.present?
if self_hosted?
Setting.onboarding_state == "invite_only"
Setting.onboarding_state == "invite_only" && Setting.invite_only_default_family_id.blank?
else
ENV["REQUIRE_INVITE_CODE"] == "true"
end

View File

@@ -540,13 +540,8 @@ class EnableBankingItemsController < ApplicationController
)
end
# Generate the callback URL for Enable Banking OAuth
# In production, uses the standard Rails route
# In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL)
def enable_banking_callback_url
return callback_enable_banking_items_url if Rails.env.production?
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/enable_banking_items/callback"
helpers.enable_banking_callback_url
end
# Validate redirect URLs from Enable Banking API to prevent open redirect attacks

View File

@@ -26,7 +26,11 @@ class FamilyExportsController < ApplicationController
[ t("breadcrumbs.home"), root_path ],
[ t("breadcrumbs.exports"), family_exports_path ]
]
render layout: "settings"
respond_to do |format|
format.html { render layout: "settings" }
format.turbo_stream { redirect_to family_exports_path }
end
end
def download

View File

@@ -1,11 +1,12 @@
class HoldingsController < ApplicationController
before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security]
before_action :set_holding, only: %i[show update destroy unlock_cost_basis remap_security reset_security sync_prices]
def index
@account = Current.family.accounts.find(params[:account_id])
end
def show
@last_price_updated = @holding.security.prices.maximum(:updated_at)
end
def update
@@ -70,6 +71,13 @@ class HoldingsController < ApplicationController
return
end
# The user explicitly selected this security from provider search results,
# so we know the provider can handle it. Bring it back online if it was
# previously marked offline (e.g. by a failed QIF import resolution).
if new_security.offline?
new_security.update!(offline: false, failed_fetch_count: 0, failed_fetch_at: nil)
end
@holding.remap_security!(new_security)
flash[:notice] = t(".success")
@@ -79,6 +87,44 @@ class HoldingsController < ApplicationController
end
end
def sync_prices
security = @holding.security
if security.offline?
redirect_to account_path(@holding.account, tab: "holdings"),
alert: t("holdings.sync_prices.unavailable")
return
end
prices_updated, @provider_error = security.import_provider_prices(
start_date: 31.days.ago.to_date,
end_date: Date.current,
clear_cache: true
)
security.import_provider_details
@last_price_updated = @holding.security.prices.maximum(:updated_at)
if prices_updated == 0
@provider_error = @provider_error.presence || t("holdings.sync_prices.provider_error")
respond_to do |format|
format.html { redirect_to account_path(@holding.account, tab: "holdings"), alert: @provider_error }
format.turbo_stream
end
return
end
strategy = @holding.account.linked? ? :reverse : :forward
Balance::Materializer.new(@holding.account, strategy: strategy, security_ids: [ @holding.security_id ]).materialize_balances
@holding.reload
@last_price_updated = @holding.security.prices.maximum(:updated_at)
respond_to do |format|
format.html { redirect_to account_path(@holding.account, tab: "holdings"), notice: t("holdings.sync_prices.success") }
format.turbo_stream
end
end
def reset_security
@holding.reset_security_to_provider!
flash[:notice] = t(".success")

View File

@@ -9,7 +9,7 @@ class Import::CleansController < ApplicationController
return redirect_to redirect_path, alert: "Please configure your import before proceeding."
end
rows = @import.rows.ordered
rows = @import.rows_ordered
if params[:view] == "errors"
rows = rows.reject { |row| row.valid? }

View File

@@ -0,0 +1,68 @@
class Import::QifCategorySelectionsController < ApplicationController
layout "imports"
before_action :set_import
def show
@categories = @import.row_categories
@tags = @import.row_tags
@category_counts = @import.rows.group(:category).count.reject { |k, _| k.blank? }
@tag_counts = compute_tag_counts
@split_categories = @import.split_categories
@has_split_transactions = @import.has_split_transactions?
end
def update
all_categories = @import.row_categories
all_tags = @import.row_tags
selected_categories = Array(selection_params[:categories]).reject(&:blank?)
selected_tags = Array(selection_params[:tags]).reject(&:blank?)
deselected_categories = all_categories - selected_categories
deselected_tags = all_tags - selected_tags
ActiveRecord::Base.transaction do
# Clear category on rows whose category was deselected
if deselected_categories.any?
@import.rows.where(category: deselected_categories).update_all(category: "")
end
# Strip deselected tags from any row that carries them
if deselected_tags.any?
@import.rows.where.not(tags: [ nil, "" ]).find_each do |row|
remaining = row.tags_list - deselected_tags
remaining.reject!(&:blank?)
updated_tags = remaining.join("|")
row.update_column(:tags, updated_tags) if updated_tags != row.tags.to_s
end
end
@import.sync_mappings
end
redirect_to import_clean_path(@import), notice: "Categories and tags saved."
end
private
def set_import
@import = Current.family.imports.find(params[:import_id])
unless @import.is_a?(QifImport)
redirect_to imports_path
end
end
def compute_tag_counts
counts = Hash.new(0)
@import.rows.each do |row|
row.tags_list.each { |tag| counts[tag] += 1 unless tag.blank? }
end
counts
end
def selection_params
params.permit(categories: [], tags: [])
end
end

View File

@@ -14,8 +14,10 @@ class Import::UploadsController < ApplicationController
end
def update
if csv_valid?(csv_str)
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
if @import.is_a?(QifImport)
handle_qif_upload
elsif csv_valid?(csv_str)
@import.account = Current.family.accounts.find_by(id: import_account_id)
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
@import.save!(validate: false)
@@ -32,6 +34,28 @@ class Import::UploadsController < ApplicationController
@import = Current.family.imports.find(params[:import_id])
end
def handle_qif_upload
unless QifParser.valid?(csv_str)
flash.now[:alert] = "Must be a valid QIF file"
render :show, status: :unprocessable_entity and return
end
unless import_account_id.present?
flash.now[:alert] = "Please select an account for the QIF import"
render :show, status: :unprocessable_entity and return
end
ActiveRecord::Base.transaction do
@import.account = Current.family.accounts.find(import_account_id)
@import.raw_file_str = QifParser.normalize_encoding(csv_str)
@import.save!(validate: false)
@import.generate_rows_from_csv
@import.sync_mappings
end
redirect_to import_qif_category_selection_path(@import), notice: "QIF file uploaded successfully."
end
def csv_str
@csv_str ||= upload_params[:import_file]&.read || upload_params[:raw_file_str]
end
@@ -50,4 +74,8 @@ class Import::UploadsController < ApplicationController
def upload_params
params.require(:import).permit(:raw_file_str, :import_file, :col_sep)
end
def import_account_id
params.require(:import).permit(:account_id)[:account_id]
end
end

View File

@@ -92,7 +92,10 @@ class ImportsController < ApplicationController
end
def show
return unless @import.requires_csv_workflow?
unless @import.requires_csv_workflow?
redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload") unless @import.uploaded?
return
end
if !@import.uploaded?
redirect_to import_upload_path(@import), alert: t("imports.show.finalize_upload")

View File

@@ -1,3 +1,5 @@
class InvestmentsController < ApplicationController
include AccountableResource
permitted_accountable_attributes :id, :subtype
end

View File

@@ -1,12 +1,12 @@
class InviteCodesController < ApplicationController
before_action :ensure_self_hosted
before_action :ensure_super_admin
def index
@invite_codes = InviteCode.all
end
def create
raise StandardError, "You are not allowed to generate invite codes" unless Current.user.admin?
InviteCode.generate!
redirect_back_or_to invite_codes_path, notice: "Code generated"
end
@@ -22,4 +22,8 @@ class InviteCodesController < ApplicationController
def ensure_self_hosted
redirect_to root_path unless self_hosted?
end
def ensure_super_admin
redirect_to root_path, alert: t("settings.hostings.not_authorized") unless Current.user.super_admin?
end
end

View File

@@ -0,0 +1,150 @@
class McpController < ApplicationController
PROTOCOL_VERSION = "2025-03-26"
# Skip session-based auth and CSRF — this is a token-authenticated API
skip_authentication
skip_before_action :verify_authenticity_token
skip_before_action :require_onboarding_and_upgrade
skip_before_action :set_default_chat
skip_before_action :detect_os
before_action :authenticate_mcp_token!
def handle
body = parse_request_body
return if performed?
unless valid_jsonrpc?(body)
render_jsonrpc_error(body&.dig("id"), -32600, "Invalid Request")
return
end
request_id = body["id"]
# JSON-RPC notifications omit the id field — server must not respond
unless body.key?("id")
return head(:no_content)
end
result = dispatch_jsonrpc(request_id, body["method"], body["params"])
return if performed?
render json: { jsonrpc: "2.0", id: request_id, result: result }
end
private
def parse_request_body
JSON.parse(request.raw_post)
rescue JSON::ParserError
render_jsonrpc_error(nil, -32700, "Parse error")
nil
end
def valid_jsonrpc?(body)
body.is_a?(Hash) && body["jsonrpc"] == "2.0" && body["method"].present?
end
def dispatch_jsonrpc(request_id, method, params)
case method
when "initialize"
handle_initialize
when "tools/list"
handle_tools_list
when "tools/call"
handle_tools_call(request_id, params)
else
render_jsonrpc_error(request_id, -32601, "Method not found: #{method}")
nil
end
end
def handle_initialize
{
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: { name: "sure", version: "1.0" }
}
end
def handle_tools_list
tools = Assistant.function_classes.map do |fn_class|
fn_instance = fn_class.new(mcp_user)
{
name: fn_instance.name,
description: fn_instance.description,
inputSchema: fn_instance.params_schema
}
end
{ tools: tools }
end
def handle_tools_call(request_id, params)
name = params&.dig("name")
arguments = params&.dig("arguments") || {}
fn_class = Assistant.function_classes.find { |fc| fc.name == name }
unless fn_class
render_jsonrpc_error(request_id, -32602, "Unknown tool: #{name}")
return nil
end
fn = fn_class.new(mcp_user)
result = fn.call(arguments)
{ content: [ { type: "text", text: result.to_json } ] }
rescue => e
Rails.logger.error "MCP tools/call error: #{e.message}"
{ content: [ { type: "text", text: { error: e.message }.to_json } ], isError: true }
end
def authenticate_mcp_token!
expected = ENV["MCP_API_TOKEN"]
unless expected.present?
render json: { error: "MCP endpoint not configured" }, status: :service_unavailable
return
end
token = request.headers["Authorization"]&.delete_prefix("Bearer ")&.strip
unless ActiveSupport::SecurityUtils.secure_compare(token.to_s, expected)
render json: { error: "unauthorized" }, status: :unauthorized
return
end
setup_mcp_user
end
def setup_mcp_user
email = ENV["MCP_USER_EMAIL"]
@mcp_user = User.find_by(email: email) if email.present?
unless @mcp_user
render json: { error: "MCP user not configured" }, status: :service_unavailable
return
end
# Build a fresh session to avoid inheriting impersonation state from
# existing sessions (Current.user resolves via active_impersonator_session
# first, which could leak another user's data into MCP tool calls).
Current.session = @mcp_user.sessions.build(
user_agent: request.user_agent,
ip_address: request.ip
)
end
def mcp_user
@mcp_user
end
def render_jsonrpc_error(id, code, message)
render json: {
jsonrpc: "2.0",
id: id,
error: { code: code, message: message }
}
end
end

View File

@@ -14,9 +14,12 @@ class OidcAccountsController < ApplicationController
@email = @pending_auth["email"]
@user_exists = User.exists?(email: @email) if @email.present?
# Check for a pending invitation for this email
@pending_invitation = Invitation.pending.find_by(email: @email) if @email.present?
# Determine whether we should offer JIT account creation for this
# pending auth, based on JIT mode and allowed domains.
@allow_account_creation = !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email)
@allow_account_creation = @pending_invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(@email))
end
def create_link
@@ -94,10 +97,13 @@ class OidcAccountsController < ApplicationController
email = @pending_auth["email"]
# Check for a pending invitation for this email
invitation = Invitation.pending.find_by(email: email)
# Respect global JIT configuration: in link_only mode or when the email
# domain is not allowed, block JIT account creation and send the user
# back to the login page with a clear message.
unless !AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email)
# domain is not allowed, block JIT account creation—unless there's a
# pending invitation for this user.
unless invitation.present? || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email))
redirect_to new_session_path, alert: "SSO account creation is disabled. Please contact an administrator."
return
end
@@ -115,14 +121,20 @@ class OidcAccountsController < ApplicationController
skip_password_validation: true
)
# Create new family for this user
@user.family = Family.new
if invitation.present?
# Accept the pending invitation: join the existing family
@user.family_id = invitation.family_id
@user.role = invitation.role
else
# Create new family for this user
@user.family = Family.new
# Use provider-configured default role, or fall back to admin for family creators
# First user of an instance always becomes super_admin regardless of provider config
provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] }
provider_default_role = provider_config&.dig(:settings, :default_role)
@user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin)
# Use provider-configured default role, or fall back to admin for family creators
# First user of an instance always becomes super_admin regardless of provider config
provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] }
provider_default_role = provider_config&.dig(:settings, :default_role)
@user.role = User.role_for_new_family_creator(fallback_role: provider_default_role || :admin)
end
if @user.save
# Create the OIDC (or other SSO) identity
@@ -140,11 +152,20 @@ class OidcAccountsController < ApplicationController
)
end
# Mark invitation as accepted if one was used
invitation&.update!(accepted_at: Time.current)
# Clear pending auth from session
session.delete(:pending_oidc_auth)
@session = create_session_for(@user)
notice = accept_pending_invitation_for(@user) ? t("invitations.accept_choice.joined_household") : "Welcome! Your account has been created."
notice = if invitation.present?
t("invitations.accept_choice.joined_household")
elsif accept_pending_invitation_for(@user)
t("invitations.accept_choice.joined_household")
else
"Welcome! Your account has been created."
end
redirect_to root_path, notice: notice
else
render :new_user, status: :unprocessable_entity

View File

@@ -16,11 +16,13 @@ class PagesController < ApplicationController
family_currency = Current.family.currency
# Use IncomeStatement for all cashflow data (now includes categorized trades)
income_totals = Current.family.income_statement.income_totals(period: @period)
expense_totals = Current.family.income_statement.expense_totals(period: @period)
income_statement = Current.family.income_statement
income_totals = income_statement.income_totals(period: @period)
expense_totals = income_statement.expense_totals(period: @period)
net_totals = income_statement.net_category_totals(period: @period)
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(expense_totals)
@cashflow_sankey_data = build_cashflow_sankey_data(net_totals, income_totals, expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(net_totals)
@dashboard_sections = build_dashboard_sections
@@ -143,7 +145,7 @@ class PagesController < ApplicationController
Provider::Registry.get_provider(:github)
end
def build_cashflow_sankey_data(income_totals, expense_totals, currency)
def build_cashflow_sankey_data(net_totals, income_totals, expense_totals, currency)
nodes = []
links = []
node_indices = {}
@@ -155,30 +157,33 @@ class PagesController < ApplicationController
end
}
total_income = income_totals.total.to_f.round(2)
total_expense = expense_totals.total.to_f.round(2)
total_income = net_totals.total_net_income.to_f.round(2)
total_expense = net_totals.total_net_expense.to_f.round(2)
# Central Cash Flow node
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
# Process income categories (flow: subcategory -> parent -> cash_flow)
process_category_totals(
category_totals: income_totals.category_totals,
# Build netted subcategory data from raw totals
net_subcategories_by_parent = build_net_subcategories(expense_totals, income_totals)
# Process net income categories (flow: subcategory -> parent -> cash_flow)
process_net_category_nodes(
categories: net_totals.net_income_categories,
total: total_income,
prefix: "income",
default_color: Category::UNCATEGORIZED_COLOR,
net_subcategories_by_parent: net_subcategories_by_parent,
add_node: add_node,
links: links,
cash_flow_idx: cash_flow_idx,
flow_direction: :inbound
)
# Process expense categories (flow: cash_flow -> parent -> subcategory)
process_category_totals(
category_totals: expense_totals.category_totals,
# Process net expense categories (flow: cash_flow -> parent -> subcategory)
process_net_category_nodes(
categories: net_totals.net_expense_categories,
total: total_expense,
prefix: "expense",
default_color: Category::UNCATEGORIZED_COLOR,
net_subcategories_by_parent: net_subcategories_by_parent,
add_node: add_node,
links: links,
cash_flow_idx: cash_flow_idx,
@@ -196,12 +201,124 @@ class PagesController < ApplicationController
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol }
end
def build_outflows_donut_data(expense_totals)
currency_symbol = Money::Currency.new(expense_totals.currency).symbol
total = expense_totals.total
# Nets subcategory expense and income totals, grouped by parent_id.
# Returns { parent_id => [ { category:, total: net_amount }, ... ] }
# Only includes subcategories with positive net (same direction as parent).
def build_net_subcategories(expense_totals, income_totals)
expense_subs = expense_totals.category_totals
.select { |ct| ct.category.parent_id.present? }
.index_by { |ct| ct.category.id }
categories = expense_totals.category_totals
.reject { |ct| ct.category.parent_id.present? || ct.total.zero? }
income_subs = income_totals.category_totals
.select { |ct| ct.category.parent_id.present? }
.index_by { |ct| ct.category.id }
all_sub_ids = (expense_subs.keys + income_subs.keys).uniq
result = {}
all_sub_ids.each do |sub_id|
exp_ct = expense_subs[sub_id]
inc_ct = income_subs[sub_id]
exp_total = exp_ct&.total || 0
inc_total = inc_ct&.total || 0
net = exp_total - inc_total
category = exp_ct&.category || inc_ct&.category
next if net.zero?
parent_id = category.parent_id
result[parent_id] ||= []
result[parent_id] << { category: category, total: net.abs, net_direction: net > 0 ? :expense : :income }
end
result
end
# Builds sankey nodes/links for net categories with subcategory hierarchy.
# Subcategories matching the parent's flow direction are shown as children.
# Subcategories with opposite net direction appear on the OTHER side of the
# sankey (handled when the other side calls this method).
#
# flow_direction: :inbound (subcategory -> parent -> cash_flow) for income
# :outbound (cash_flow -> parent -> subcategory) for expenses
def process_net_category_nodes(categories:, total:, prefix:, net_subcategories_by_parent:, add_node:, links:, cash_flow_idx:, flow_direction:)
matching_direction = flow_direction == :inbound ? :income : :expense
categories.each do |ct|
val = ct.total.to_f.round(2)
next if val.zero?
percentage = total.zero? ? 0 : (val / total * 100).round(1)
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
node_key = "#{prefix}_#{ct.category.id || ct.category.name}"
all_subs = ct.category.id ? (net_subcategories_by_parent[ct.category.id] || []) : []
same_side_subs = all_subs.select { |s| s[:net_direction] == matching_direction }
# Also check if any subcategory has opposite direction — those will be
# rendered by the OTHER side's call to this method, linked to cash_flow
# directly (they appear as independent nodes on the opposite side).
opposite_subs = all_subs.select { |s| s[:net_direction] != matching_direction }
if same_side_subs.any?
parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color)
if flow_direction == :inbound
links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
else
links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage }
end
same_side_subs.each do |sub|
sub_val = sub[:total].to_f.round(2)
sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1)
sub_color = sub[:category].color.presence || color
sub_key = "#{prefix}_sub_#{sub[:category].id}"
sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color)
if flow_direction == :inbound
links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct }
else
links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct }
end
end
else
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
if flow_direction == :inbound
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
else
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
end
# Render opposite-direction subcategories as standalone nodes on this side,
# linked directly to cash_flow. They represent subcategory surplus/deficit
# that goes against the parent's overall direction.
opposite_prefix = flow_direction == :inbound ? "expense" : "income"
opposite_subs.each do |sub|
sub_val = sub[:total].to_f.round(2)
sub_pct = total.zero? ? 0 : (sub_val / total * 100).round(1)
sub_color = sub[:category].color.presence || color
sub_key = "#{opposite_prefix}_sub_#{sub[:category].id}"
sub_idx = add_node.call(sub_key, sub[:category].name, sub_val, sub_pct, sub_color)
# Opposite direction: if parent is outbound (expense), this sub is inbound (income)
if flow_direction == :inbound
links << { source: cash_flow_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct }
else
links << { source: sub_idx, target: cash_flow_idx, value: sub_val, color: sub_color, percentage: sub_pct }
end
end
end
end
def build_outflows_donut_data(net_totals)
currency_symbol = Money::Currency.new(net_totals.currency).symbol
total = net_totals.total_net_expense
categories = net_totals.net_expense_categories
.reject { |ct| ct.total.zero? }
.sort_by { |ct| -ct.total }
.map do |ct|
{
@@ -216,66 +333,7 @@ class PagesController < ApplicationController
}
end
{ categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol }
end
# Processes category totals for sankey diagram, handling parent/subcategory relationships.
# flow_direction: :inbound (subcategory -> parent -> cash_flow) for income
# :outbound (cash_flow -> parent -> subcategory) for expenses
def process_category_totals(category_totals:, total:, prefix:, default_color:, add_node:, links:, cash_flow_idx:, flow_direction:)
# Build lookup of subcategories by parent_id
subcategories_by_parent = category_totals
.select { |ct| ct.category.parent_id.present? && ct.total.to_f > 0 }
.group_by { |ct| ct.category.parent_id }
category_totals.each do |ct|
next if ct.category.parent_id.present? # Skip subcategories in first pass
val = ct.total.to_f.round(2)
next if val.zero?
percentage = total.zero? ? 0 : (val / total * 100).round(1)
color = ct.category.color.presence || default_color
node_key = "#{prefix}_#{ct.category.id || ct.category.name}"
subs = subcategories_by_parent[ct.category.id] || []
if subs.any?
parent_idx = add_node.call(node_key, ct.category.name, val, percentage, color)
# Link parent to/from cash flow based on direction
if flow_direction == :inbound
links << { source: parent_idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
else
links << { source: cash_flow_idx, target: parent_idx, value: val, color: color, percentage: percentage }
end
# Add subcategory nodes
subs.each do |sub_ct|
sub_val = sub_ct.total.to_f.round(2)
sub_pct = val.zero? ? 0 : (sub_val / val * 100).round(1)
sub_color = sub_ct.category.color.presence || color
sub_key = "#{prefix}_sub_#{sub_ct.category.id}"
sub_idx = add_node.call(sub_key, sub_ct.category.name, sub_val, sub_pct, sub_color)
# Link subcategory to/from parent based on direction
if flow_direction == :inbound
links << { source: sub_idx, target: parent_idx, value: sub_val, color: sub_color, percentage: sub_pct }
else
links << { source: parent_idx, target: sub_idx, value: sub_val, color: sub_color, percentage: sub_pct }
end
end
else
# No subcategories, link directly to/from cash flow
idx = add_node.call(node_key, ct.category.name, val, percentage, color)
if flow_direction == :inbound
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
else
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
end
end
{ categories: categories, total: total.to_f.round(2), currency: net_totals.currency, currency_symbol: currency_symbol }
end
def ensure_intro_guest!

View File

@@ -0,0 +1,82 @@
class PendingDuplicateMergesController < ApplicationController
before_action :set_transaction
def new
@limit = 10
# Ensure offset is non-negative to prevent abuse
@offset = [ (params[:offset] || 0).to_i, 0 ].max
# Fetch one extra to determine if there are more results
candidates = @transaction.pending_duplicate_candidates(limit: @limit + 1, offset: @offset).to_a
@has_more = candidates.size > @limit
@potential_duplicates = candidates.first(@limit)
# Calculate range for display (e.g., "1-10", "11-20")
@range_start = @offset + 1
@range_end = @offset + @potential_duplicates.count
end
def create
# Manually merge the pending transaction with the selected posted transaction
unless merge_params[:posted_entry_id].present?
redirect_back_or_to transactions_path, alert: "Please select a posted transaction to merge with"
return
end
# Validate the posted entry is an eligible candidate (same account, currency, not pending)
posted_entry = find_eligible_posted_entry(merge_params[:posted_entry_id])
unless posted_entry
redirect_back_or_to transactions_path, alert: "Invalid transaction selected for merge"
return
end
# Store the merge suggestion and immediately execute it
@transaction.update!(
extra: (@transaction.extra || {}).merge(
"potential_posted_match" => {
"entry_id" => posted_entry.id,
"reason" => "manual_match",
"posted_amount" => posted_entry.amount.to_s,
"confidence" => "high", # Manual matches are high confidence
"detected_at" => Date.current.to_s
}
)
)
# Immediately merge
if @transaction.merge_with_duplicate!
redirect_back_or_to transactions_path, notice: "Pending transaction merged with posted transaction"
else
redirect_back_or_to transactions_path, alert: "Could not merge transactions"
end
end
private
def set_transaction
entry = Current.family.entries.find(params[:transaction_id])
@transaction = entry.entryable
unless @transaction.is_a?(Transaction) && @transaction.pending?
redirect_to transactions_path, alert: "This feature is only available for pending transactions"
end
end
def find_eligible_posted_entry(entry_id)
# Constrain to same account, currency, and ensure it's a posted transaction
# Use the same logic as pending_duplicate_candidates to ensure consistency
conditions = Transaction::PENDING_PROVIDERS.map { |provider| "(transactions.extra -> '#{provider}' ->> 'pending')::boolean IS NOT TRUE" }
@transaction.entry.account.entries
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
.where(id: entry_id)
.where(currency: @transaction.entry.currency)
.where.not(id: @transaction.entry.id)
.where(conditions.join(" AND "))
.first
end
def merge_params
params.require(:pending_duplicate_merges).permit(:posted_entry_id)
end
end

View File

@@ -18,6 +18,11 @@ class RegistrationsController < ApplicationController
@user.family = @invitation.family
@user.role = @invitation.role
@user.email = @invitation.email
elsif (default_family_id = Setting.invite_only_default_family_id).present? &&
Setting.onboarding_state == "invite_only" &&
(default_family = Family.find_by(id: default_family_id))
@user.family = default_family
@user.role = :member
else
family = Family.new
@user.family = family

View File

@@ -188,10 +188,10 @@ class SessionsController < ApplicationController
redirect_to root_path
end
else
# Mobile SSO with no linked identity - redirect back with error
# Mobile SSO with no linked identity - cache pending auth and redirect
# back to the app with a linking code so the user can link or create an account
if session[:mobile_sso].present?
session.delete(:mobile_sso)
mobile_sso_redirect(error: "account_not_linked", message: "Please link your Google account from the web app first")
handle_mobile_sso_onboarding(auth)
return
end
@@ -273,6 +273,41 @@ class SessionsController < ApplicationController
mobile_sso_redirect(error: "device_error", message: "Unable to register device")
end
def handle_mobile_sso_onboarding(auth)
device_info = session.delete(:mobile_sso)
email = auth.info&.email
has_pending_invitation = email.present? && Invitation.pending.exists?(email: email)
allow_creation = has_pending_invitation || (!AuthConfig.jit_link_only? && AuthConfig.allowed_oidc_domain?(email))
linking_code = SecureRandom.urlsafe_base64(32)
Rails.cache.write(
"mobile_sso_link:#{linking_code}",
{
provider: auth.provider,
uid: auth.uid,
email: email,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name,
name: auth.info&.name,
issuer: auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss"),
device_info: device_info,
allow_account_creation: allow_creation
},
expires_in: 10.minutes
)
mobile_sso_redirect(
status: "account_not_linked",
linking_code: linking_code,
email: email,
first_name: auth.info&.first_name,
last_name: auth.info&.last_name,
allow_account_creation: allow_creation,
has_pending_invitation: has_pending_invitation
)
end
def mobile_sso_redirect(params = {})
redirect_to "sureapp://oauth/callback?#{params.to_query}", allow_other_host: true
end

View File

@@ -3,7 +3,8 @@ class Settings::HostingsController < ApplicationController
guard_feature unless: -> { self_hosted? }
before_action :ensure_admin, only: [ :update, :clear_cache ]
before_action :ensure_admin, only: [ :update, :clear_cache, :disconnect_external_assistant ]
before_action :ensure_super_admin_for_onboarding, only: :update
def show
@breadcrumbs = [
@@ -43,6 +44,11 @@ class Settings::HostingsController < ApplicationController
Setting.require_email_confirmation = hosting_params[:require_email_confirmation]
end
if hosting_params.key?(:invite_only_default_family_id)
value = hosting_params[:invite_only_default_family_id].presence
Setting.invite_only_default_family_id = value
end
if hosting_params.key?(:brand_fetch_client_id)
Setting.brand_fetch_client_id = hosting_params[:brand_fetch_client_id]
end
@@ -118,6 +124,23 @@ class Settings::HostingsController < ApplicationController
Setting.openai_json_mode = hosting_params[:openai_json_mode].presence
end
if hosting_params.key?(:external_assistant_url)
Setting.external_assistant_url = hosting_params[:external_assistant_url]
end
if hosting_params.key?(:external_assistant_token)
token_param = hosting_params[:external_assistant_token].to_s.strip
unless token_param.blank? || token_param == "********"
Setting.external_assistant_token = token_param
end
end
if hosting_params.key?(:external_assistant_agent_id)
Setting.external_assistant_agent_id = hosting_params[:external_assistant_agent_id]
end
update_assistant_type
redirect_to settings_hosting_path, notice: t(".success")
rescue Setting::ValidationError => error
flash.now[:alert] = error.message
@@ -129,15 +152,41 @@ class Settings::HostingsController < ApplicationController
redirect_to settings_hosting_path, notice: t(".cache_cleared")
end
def disconnect_external_assistant
Setting.external_assistant_url = nil
Setting.external_assistant_token = nil
Setting.external_assistant_agent_id = nil
Current.family.update!(assistant_type: "builtin") unless ENV["ASSISTANT_TYPE"].present?
redirect_to settings_hosting_path, notice: t(".external_assistant_disconnected")
rescue => e
Rails.logger.error("[External Assistant] Disconnect failed: #{e.message}")
redirect_to settings_hosting_path, alert: t("settings.hostings.update.failure")
end
private
def hosting_params
params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time)
return ActionController::Parameters.new unless params.key?(:setting)
params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :invite_only_default_family_id, :brand_fetch_client_id, :brand_fetch_high_res_logos, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider, :syncs_include_pending, :auto_sync_enabled, :auto_sync_time, :external_assistant_url, :external_assistant_token, :external_assistant_agent_id)
end
def update_assistant_type
return unless params[:family].present? && params[:family][:assistant_type].present?
return if ENV["ASSISTANT_TYPE"].present?
assistant_type = params[:family][:assistant_type]
Current.family.update!(assistant_type: assistant_type) if Family::ASSISTANT_TYPES.include?(assistant_type)
end
def ensure_admin
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
end
def ensure_super_admin_for_onboarding
onboarding_params = %i[onboarding_state invite_only_default_family_id]
return unless onboarding_params.any? { |p| hosting_params.key?(p) }
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.super_admin?
end
def sync_auto_sync_scheduler!
AutoSyncScheduler.sync!
rescue StandardError => error

View File

@@ -0,0 +1,98 @@
class TransactionAttachmentsController < ApplicationController
before_action :set_transaction
before_action :set_attachment, only: [ :show, :destroy ]
def show
disposition = params[:disposition] == "attachment" ? "attachment" : "inline"
redirect_to rails_blob_url(@attachment, disposition: disposition)
end
def create
attachments = attachment_params
if attachments.present?
@transaction.with_lock do
# Check attachment count limit before attaching
current_count = @transaction.attachments.count
new_count = attachments.is_a?(Array) ? attachments.length : 1
if current_count + new_count > Transaction::MAX_ATTACHMENTS_PER_TRANSACTION
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.cannot_exceed", count: Transaction::MAX_ATTACHMENTS_PER_TRANSACTION) }
end
return
end
existing_ids = @transaction.attachments.pluck(:id)
attachment_proxy = @transaction.attachments.attach(attachments)
if @transaction.valid?
count = new_count
message = count == 1 ? t("transactions.attachments.uploaded_one") : t("transactions.attachments.uploaded_many", count: count)
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), notice: message }
format.turbo_stream { flash.now[:notice] = message }
end
else
# Remove invalid attachments
newly_added = Array(attachment_proxy).reject { |a| existing_ids.include?(a.id) }
newly_added.each(&:purge)
error_messages = @transaction.errors.full_messages_for(:attachments).join(", ")
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.failed_upload", error: error_messages) }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.failed_upload", error: error_messages) }
end
end
end
else
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.no_files_selected") }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.no_files_selected") }
end
end
rescue => e
logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.upload_failed") }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.upload_failed") }
end
end
def destroy
@attachment.purge
message = t("transactions.attachments.attachment_deleted")
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), notice: message }
format.turbo_stream { flash.now[:notice] = message }
end
rescue => e
logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
respond_to do |format|
format.html { redirect_back_or_to transaction_path(@transaction), alert: t("transactions.attachments.delete_failed") }
format.turbo_stream { flash.now[:alert] = t("transactions.attachments.delete_failed") }
end
end
private
def set_transaction
@transaction = Current.family.transactions.find(params[:transaction_id])
end
def set_attachment
@attachment = @transaction.attachments.find(params[:id])
end
def attachment_params
if params.has_key?(:attachments)
Array(params.fetch(:attachments, [])).reject(&:blank?).map do |param|
param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param
end
elsif params.has_key?(:attachment)
param = params[:attachment]
return nil if param.blank?
param.respond_to?(:permit) ? param.permit(:file, :filename, :content_type, :description, :metadata) : param
end
end
end

View File

@@ -5,9 +5,12 @@ class TransactionsController < ApplicationController
before_action :store_params!, only: :index
def new
prefill_params_from_duplicate!
super
apply_duplicate_attributes!
@income_categories = Current.family.categories.incomes.alphabetically
@expense_categories = Current.family.categories.expenses.alphabetically
@categories = Current.family.categories.alphabetically
end
def index
@@ -307,6 +310,35 @@ class TransactionsController < ApplicationController
end
private
def duplicate_source
return @duplicate_source if defined?(@duplicate_source)
@duplicate_source = if params[:duplicate_entry_id].present?
source = Current.family.entries.find_by(id: params[:duplicate_entry_id])
source if source&.transaction?
end
end
def prefill_params_from_duplicate!
return unless duplicate_source
params[:nature] ||= duplicate_source.amount.negative? ? "inflow" : "outflow"
params[:account_id] ||= duplicate_source.account_id.to_s
end
def apply_duplicate_attributes!
return unless duplicate_source
@entry.assign_attributes(
name: duplicate_source.name,
amount: duplicate_source.amount.abs,
currency: duplicate_source.currency,
notes: duplicate_source.notes
)
@entry.entryable.assign_attributes(
category_id: duplicate_source.entryable.category_id,
merchant_id: duplicate_source.entryable.merchant_id
)
@entry.entryable.tag_ids = duplicate_source.entryable.tag_ids
end
def set_entry_for_unlock
transaction = Current.family.transactions.find(params[:id])
@entry = transaction.entry
@@ -332,6 +364,8 @@ class TransactionsController < ApplicationController
nature = entry_params.delete(:nature)
entry_params.delete(:amount) if entry_params[:amount].blank?
if nature.present? && entry_params[:amount].present?
signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
entry_params = entry_params.merge(amount: signed_amount)

View File

@@ -9,7 +9,7 @@ class TransfersController < ApplicationController
end
def show
@categories = Current.family.categories.expenses
@categories = Current.family.categories.alphabetically
end
def create
@@ -17,7 +17,7 @@ class TransfersController < ApplicationController
family: Current.family,
source_account_id: transfer_params[:from_account_id],
destination_account_id: transfer_params[:to_account_id],
date: transfer_params[:date],
date: Date.parse(transfer_params[:date]),
amount: transfer_params[:amount].to_d
).create

View File

@@ -108,6 +108,11 @@ module ApplicationHelper
cookies[:admin] == "true"
end
def assistant_icon
type = ENV["ASSISTANT_TYPE"].presence || Current.family&.assistant_type.presence || "builtin"
type == "external" ? "claw" : "ai"
end
def default_ai_model
# Always return a valid model, never nil or empty
# Delegates to Chat.default_model for consistency
@@ -139,6 +144,15 @@ module ApplicationHelper
markdown.render(text).html_safe
end
# Generate the callback URL for Enable Banking OAuth (used in views and controller).
# In production, uses the standard Rails route.
# In development, uses DEV_WEBHOOKS_URL if set (e.g., ngrok URL).
def enable_banking_callback_url
return callback_enable_banking_items_url if Rails.env.production?
ENV.fetch("DEV_WEBHOOKS_URL", root_url).chomp("/") + "/enable_banking_items/callback"
end
# Formats quantity with adaptive precision based on the value size.
# Shows more decimal places for small quantities (common with crypto).
#

View File

@@ -25,7 +25,6 @@ module ImportsHelper
entity_type: "Type",
category_parent: "Parent category",
category_color: "Color",
category_classification: "Classification",
category_icon: "Lucide icon"
}[key]
end

View File

@@ -28,11 +28,25 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
end
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
field_options = normalize_options(options, html_options)
selected_value = @object.public_send(method) if @object.respond_to?(method)
placeholder = options[:prompt] || options[:include_blank] || options[:placeholder] || I18n.t("helpers.select.default_label")
build_field(method, field_options, html_options) do |merged_html_options|
super(method, collection, value_method, text_method, options, merged_html_options)
end
@template.render(
DS::Select.new(
form: self,
method: method,
items: collection.map { |item| { value: item.public_send(value_method), label: item.public_send(text_method), object: item } },
selected: selected_value,
placeholder: placeholder,
searchable: options.fetch(:searchable, false),
variant: options.fetch(:variant, :simple),
include_blank: options[:include_blank],
label: options[:label],
container_class: options[:container_class],
label_tooltip: options[:label_tooltip],
html_options: html_options
)
)
end
def money_field(amount_method, options = {})

View File

@@ -0,0 +1,22 @@
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="admin-invitation-delete"
// Handles individual invitation deletion and alt-click to delete all family invitations
export default class extends Controller {
static targets = [ "button", "destroyAllForm" ]
static values = { deleteAllLabel: String }
handleClick(event) {
if (event.altKey) {
event.preventDefault()
this.buttonTargets.forEach(btn => {
btn.textContent = this.deleteAllLabelValue
})
if (this.hasDestroyAllFormTarget) {
this.destroyAllFormTarget.requestSubmit()
}
}
}
}

View File

@@ -0,0 +1,63 @@
import { Controller } from "@hotwired/stimulus"
export default class AttachmentUploadController extends Controller {
static targets = ["fileInput", "submitButton", "fileName", "uploadText"]
static values = {
maxFiles: Number,
maxSize: Number
}
connect() {
this.updateSubmitButton()
}
triggerFileInput() {
this.fileInputTarget.click()
}
updateSubmitButton() {
const files = Array.from(this.fileInputTarget.files)
const hasFiles = files.length > 0
// Basic validation hints (server validates definitively)
let isValid = hasFiles
let errorMessage = ""
if (hasFiles) {
if (this.hasUploadTextTarget) this.uploadTextTarget.classList.add("hidden")
if (this.hasFileNameTarget) {
const filenames = files.map(f => f.name).join(", ")
const textElement = this.fileNameTarget.querySelector("p")
if (textElement) textElement.textContent = filenames
this.fileNameTarget.classList.remove("hidden")
}
// Check file count
if (files.length > this.maxFilesValue) {
isValid = false
errorMessage = `Too many files (max ${this.maxFilesValue})`
}
// Check file sizes
const oversizedFiles = files.filter(file => file.size > this.maxSizeValue)
if (oversizedFiles.length > 0) {
isValid = false
errorMessage = `File too large (max ${Math.round(this.maxSizeValue / 1024 / 1024)}MB)`
}
} else {
if (this.hasUploadTextTarget) this.uploadTextTarget.classList.remove("hidden")
if (this.hasFileNameTarget) this.fileNameTarget.classList.add("hidden")
}
this.submitButtonTarget.disabled = !isValid
if (hasFiles && isValid) {
const count = files.length
this.submitButtonTarget.textContent = count === 1 ? "Upload 1 file" : `Upload ${count} files`
} else if (errorMessage) {
this.submitButtonTarget.textContent = errorMessage
} else {
this.submitButtonTarget.textContent = "Upload"
}
}
}

View File

@@ -8,6 +8,7 @@ export default class extends Controller {
"selectionBar",
"selectionBarText",
"bulkEditDrawerHeader",
"duplicateLink",
];
static values = {
singularLabel: String,
@@ -135,6 +136,18 @@ export default class extends Controller {
this.selectionBarTarget.classList.toggle("hidden", count === 0);
this.selectionBarTarget.querySelector("input[type='checkbox']").checked =
count > 0;
if (this.hasDuplicateLinkTarget) {
this.duplicateLinkTarget.classList.toggle("hidden", count !== 1);
if (count === 1) {
const url = new URL(
this.duplicateLinkTarget.href,
window.location.origin,
);
url.searchParams.set("duplicate_entry_id", this.selectedIdsValue[0]);
this.duplicateLinkTarget.href = url.toString();
}
}
}
_pluralizedResourceName() {

View File

@@ -0,0 +1,18 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input"]
onSelect(event) {
this.inputTarget.value = event.detail.value
const inputEvent = new Event("input", { bubbles: true })
this.inputTarget.dispatchEvent(inputEvent)
const form = this.element.closest("form")
const controllers = (form?.dataset.controller || "").split(/\s+/)
if (form && controllers.includes("auto-submit-form")) {
form.requestSubmit()
}
}
}

View File

@@ -10,11 +10,75 @@ export default class extends Controller {
};
connect() {
this.open();
this._connectionToken = (this._connectionToken ?? 0) + 1;
const connectionToken = this._connectionToken;
this.open(connectionToken).catch((error) => {
console.error("Failed to initialize Plaid Link", error);
});
}
open() {
const handler = Plaid.create({
disconnect() {
this._handler?.destroy();
this._handler = null;
this._connectionToken = (this._connectionToken ?? 0) + 1;
}
waitForPlaid() {
if (typeof Plaid !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let plaidScript = document.querySelector(
'script[src*="link-initialize.js"]'
);
// Reject if the CDN request stalls without firing load or error
const timeoutId = window.setTimeout(() => {
if (plaidScript) plaidScript.dataset.plaidState = "error";
reject(new Error("Timed out loading Plaid script"));
}, 10_000);
// Remove previously failed script so we can retry with a fresh element
if (plaidScript?.dataset.plaidState === "error") {
plaidScript.remove();
plaidScript = null;
}
if (!plaidScript) {
plaidScript = document.createElement("script");
plaidScript.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
plaidScript.async = true;
plaidScript.dataset.plaidState = "loading";
document.head.appendChild(plaidScript);
}
plaidScript.addEventListener("load", () => {
window.clearTimeout(timeoutId);
plaidScript.dataset.plaidState = "loaded";
resolve();
}, { once: true });
plaidScript.addEventListener("error", () => {
window.clearTimeout(timeoutId);
plaidScript.dataset.plaidState = "error";
reject(new Error("Failed to load Plaid script"));
}, { once: true });
// Re-check after attaching listeners in case the script loaded between
// the initial typeof check and listener attachment (avoids a permanently
// pending promise on retry flows).
if (typeof Plaid !== "undefined") {
window.clearTimeout(timeoutId);
resolve();
}
});
}
async open(connectionToken = this._connectionToken) {
await this.waitForPlaid();
if (connectionToken !== this._connectionToken) return;
this._handler = Plaid.create({
token: this.linkTokenValue,
onSuccess: this.handleSuccess,
onLoad: this.handleLoad,
@@ -22,7 +86,7 @@ export default class extends Controller {
onEvent: this.handleEvent,
});
handler.open();
this._handler.open();
}
handleSuccess = (public_token, metadata) => {

View File

@@ -35,7 +35,7 @@ export default class extends Controller {
try {
const response = await fetch(this.urlValue, {
headers: {
Accept: "text/vnd.turbo-stream.html",
Accept: "text/html",
"Turbo-Frame": this.element.id,
},
});

View File

@@ -0,0 +1,182 @@
import { Controller } from "@hotwired/stimulus"
import { autoUpdate } from "@floating-ui/dom"
export default class extends Controller {
static targets = ["button", "menu", "input"]
static values = {
placement: { type: String, default: "bottom-start" },
offset: { type: Number, default: 6 }
}
connect() {
this.isOpen = false
this.boundOutsideClick = this.handleOutsideClick.bind(this)
this.boundKeydown = this.handleKeydown.bind(this)
this.boundTurboLoad = this.handleTurboLoad.bind(this)
document.addEventListener("click", this.boundOutsideClick)
document.addEventListener("turbo:load", this.boundTurboLoad)
this.element.addEventListener("keydown", this.boundKeydown)
this.observeMenuResize()
}
disconnect() {
document.removeEventListener("click", this.boundOutsideClick)
document.removeEventListener("turbo:load", this.boundTurboLoad)
this.element.removeEventListener("keydown", this.boundKeydown)
this.stopAutoUpdate()
if (this.resizeObserver) this.resizeObserver.disconnect()
}
toggle = () => {
this.isOpen ? this.close() : this.openMenu()
}
openMenu() {
this.isOpen = true
this.menuTarget.classList.remove("hidden")
this.buttonTarget.setAttribute("aria-expanded", "true")
this.startAutoUpdate()
this.clearSearch()
requestAnimationFrame(() => {
this.menuTarget.classList.remove("opacity-0", "-translate-y-1", "pointer-events-none")
this.menuTarget.classList.add("opacity-100", "translate-y-0")
this.updatePosition()
this.scrollToSelected()
})
}
close() {
this.isOpen = false
this.stopAutoUpdate()
this.menuTarget.classList.remove("opacity-100", "translate-y-0")
this.menuTarget.classList.add("opacity-0", "-translate-y-1", "pointer-events-none")
this.buttonTarget.setAttribute("aria-expanded", "false")
setTimeout(() => { if (!this.isOpen && this.hasMenuTarget) this.menuTarget.classList.add("hidden") }, 150)
}
select(event) {
const selectedElement = event.currentTarget
const value = selectedElement.dataset.value
const label = selectedElement.dataset.filterName || selectedElement.textContent.trim()
this.buttonTarget.textContent = label
if (this.hasInputTarget) {
this.inputTarget.value = value
this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
}
const previousSelected = this.menuTarget.querySelector("[aria-selected='true']")
if (previousSelected) {
previousSelected.setAttribute("aria-selected", "false")
previousSelected.classList.remove("bg-container-inset")
const prevIcon = previousSelected.querySelector(".check-icon")
if (prevIcon) prevIcon.classList.add("hidden")
}
selectedElement.setAttribute("aria-selected", "true")
selectedElement.classList.add("bg-container-inset")
const selectedIcon = selectedElement.querySelector(".check-icon")
if (selectedIcon) selectedIcon.classList.remove("hidden")
this.element.dispatchEvent(new CustomEvent("dropdown:select", {
detail: { value, label },
bubbles: true
}))
this.close()
this.buttonTarget.focus()
}
focusSearch() {
const input = this.menuTarget.querySelector('input[type="search"]')
if (input) { input.focus({ preventScroll: true }); return true }
return false
}
focusFirstElement() {
const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
const el = this.menuTarget.querySelector(selector)
if (el) el.focus({ preventScroll: true })
}
scrollToSelected() {
const selected = this.menuTarget.querySelector(".bg-container-inset")
if (selected) selected.scrollIntoView({ block: "center" })
}
handleOutsideClick(event) {
if (this.isOpen && !this.element.contains(event.target)) this.close()
}
handleKeydown(event) {
if (!this.isOpen) return
if (event.key === "Escape") { this.close(); this.buttonTarget.focus() }
if (event.key === "Enter" && event.target.dataset.value) { event.preventDefault(); event.target.click() }
}
handleTurboLoad() { if (this.isOpen) this.close() }
clearSearch() {
const input = this.menuTarget.querySelector('input[type="search"]')
if (!input) return
input.value = ""
input.dispatchEvent(new Event("input", { bubbles: true }))
}
startAutoUpdate() {
if (!this._cleanup && this.buttonTarget && this.menuTarget) {
this._cleanup = autoUpdate(this.buttonTarget, this.menuTarget, () => this.updatePosition())
}
}
stopAutoUpdate() {
if (this._cleanup) { this._cleanup(); this._cleanup = null }
}
observeMenuResize() {
this.resizeObserver = new ResizeObserver(() => {
if (this.isOpen) requestAnimationFrame(() => this.updatePosition())
})
this.resizeObserver.observe(this.menuTarget)
}
getScrollParent(element) {
let parent = element.parentElement
while (parent) {
const style = getComputedStyle(parent)
const overflowY = style.overflowY
if (overflowY === "auto" || overflowY === "scroll") return parent
parent = parent.parentElement
}
return document.documentElement
}
updatePosition() {
if (!this.buttonTarget || !this.menuTarget || !this.isOpen) return
const container = this.getScrollParent(this.element)
const containerRect = container.getBoundingClientRect()
const buttonRect = this.buttonTarget.getBoundingClientRect()
const menuHeight = this.menuTarget.scrollHeight
const spaceBelow = containerRect.bottom - buttonRect.bottom
const spaceAbove = buttonRect.top - containerRect.top
const shouldOpenUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
this.menuTarget.style.left = "0"
this.menuTarget.style.width = "100%"
this.menuTarget.style.top = ""
this.menuTarget.style.bottom = ""
this.menuTarget.style.overflowY = "auto"
if (shouldOpenUp) {
this.menuTarget.style.bottom = "100%"
this.menuTarget.style.maxHeight = `${Math.max(0, spaceAbove - this.offsetValue)}px`
} else {
this.menuTarget.style.top = "100%"
this.menuTarget.style.maxHeight = `${Math.max(0, spaceBelow - this.offsetValue)}px`
}
}
}

View File

@@ -3,6 +3,7 @@ class DataCleanerJob < ApplicationJob
def perform
clean_old_merchant_associations
clean_expired_archived_exports
end
private
@@ -14,4 +15,10 @@ class DataCleanerJob < ApplicationJob
Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} old merchant associations") if deleted_count > 0
end
def clean_expired_archived_exports
deleted_count = ArchivedExport.expired.destroy_all.count
Rails.logger.info("DataCleanerJob: Deleted #{deleted_count} expired archived exports") if deleted_count > 0
end
end

View File

@@ -0,0 +1,80 @@
class DemoFamilyRefreshJob < ApplicationJob
queue_as :scheduled
def perform
period_end = Time.current
period_start = period_end - 24.hours
demo_email = Rails.application.config_for(:demo).fetch("email")
demo_user = User.find_by(email: demo_email)
old_family = demo_user&.family
old_family_session_count = sessions_count_for(old_family, period_start:, period_end:)
newly_created_families_count = Family.where(created_at: period_start...period_end).count
if old_family
delete_old_family_monitoring_key!(old_family)
anonymize_family_emails!(old_family)
DestroyJob.perform_later(old_family)
end
Demo::Generator.new.generate_default_data!(skip_clear: true, email: demo_email)
notify_super_admins!(
old_family:,
old_family_session_count:,
newly_created_families_count:,
period_start:,
period_end:
)
end
private
def sessions_count_for(family, period_start:, period_end:)
return 0 unless family
Session
.joins(:user)
.where(users: { family_id: family.id })
.where(created_at: period_start...period_end)
.distinct
.count(:id)
end
def delete_old_family_monitoring_key!(family)
ApiKey
.where(user_id: family.users.select(:id), display_key: ApiKey::DEMO_MONITORING_KEY)
.delete_all
end
def anonymize_family_emails!(family)
family.users.find_each do |user|
user.update_columns(
email: deleted_email_for(user),
unconfirmed_email: nil,
updated_at: Time.current
)
end
end
def deleted_email_for(user)
local_part, domain = user.email.split("@", 2)
"#{local_part}+deleting-#{user.id}-#{SecureRandom.hex(4)}@#{domain}"
end
def notify_super_admins!(old_family:, old_family_session_count:, newly_created_families_count:, period_start:, period_end:)
User.super_admin.find_each do |super_admin|
DemoFamilyRefreshMailer.with(
super_admin:,
old_family_id: old_family&.id,
old_family_name: old_family&.name,
old_family_session_count:,
newly_created_families_count:,
period_start:,
period_end:
).completed.deliver_later
end
end
end

View File

@@ -0,0 +1,64 @@
class InactiveFamilyCleanerJob < ApplicationJob
queue_as :scheduled
BATCH_SIZE = 500
ARCHIVE_EXPIRY = 90.days
def perform(dry_run: false)
return unless Rails.application.config.app_mode.managed?
families = Family.inactive_trial_for_cleanup.limit(BATCH_SIZE)
count = families.count
if count == 0
Rails.logger.info("InactiveFamilyCleanerJob: No inactive families to clean up")
return
end
Rails.logger.info("InactiveFamilyCleanerJob: Found #{count} inactive families to clean up#{' (dry run)' if dry_run}")
families.find_each do |family|
if family.requires_data_archive?
if dry_run
Rails.logger.info("InactiveFamilyCleanerJob: Would archive data for family #{family.id}")
else
archive_family_data(family)
end
end
if dry_run
Rails.logger.info("InactiveFamilyCleanerJob: Would destroy family #{family.id} (created: #{family.created_at})")
else
Rails.logger.info("InactiveFamilyCleanerJob: Destroying family #{family.id} (created: #{family.created_at})")
family.destroy
end
end
Rails.logger.info("InactiveFamilyCleanerJob: Completed cleanup of #{count} families#{' (dry run)' if dry_run}")
end
private
def archive_family_data(family)
export_data = Family::DataExporter.new(family).generate_export
email = family.users.order(:created_at).first&.email
ActiveRecord::Base.transaction do
archive = ArchivedExport.create!(
email: email || "unknown",
family_name: family.name,
expires_at: ARCHIVE_EXPIRY.from_now
)
archive.export_file.attach(
io: export_data,
filename: "sure_archive_#{family.id}.zip",
content_type: "application/zip"
)
raise ActiveRecord::Rollback, "File attach failed" unless archive.export_file.attached?
Rails.logger.info("InactiveFamilyCleanerJob: Archived data for family #{family.id} (email: #{email}, token_digest: #{archive.download_token_digest.first(8)}...)")
end
end
end

View File

@@ -0,0 +1,16 @@
class DemoFamilyRefreshMailer < ApplicationMailer
def completed
@super_admin = params.fetch(:super_admin)
@old_family_id = params[:old_family_id]
@old_family_name = params[:old_family_name]
@old_family_session_count = params.fetch(:old_family_session_count)
@newly_created_families_count = params.fetch(:newly_created_families_count)
@period_start = params.fetch(:period_start)
@period_end = params.fetch(:period_end)
mail(
to: @super_admin.email,
subject: "Demo family refresh completed"
)
end
end

View File

@@ -79,7 +79,7 @@ class Account < ApplicationRecord
super(attribute, options)
end
def create_and_sync(attributes, skip_initial_sync: false)
def create_and_sync(attributes, skip_initial_sync: false, opening_balance_date: nil)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
# Default cash_balance to balance unless explicitly provided (e.g., Crypto sets it to 0)
attrs = attributes.dup
@@ -91,7 +91,10 @@ class Account < ApplicationRecord
account.save!
manager = Account::OpeningBalanceManager.new(account)
result = manager.set_opening_balance(balance: initial_balance || account.balance)
result = manager.set_opening_balance(
balance: initial_balance || account.balance,
date: opening_balance_date
)
raise result.error if result.error
end
@@ -241,7 +244,15 @@ class Account < ApplicationRecord
end
def logo_url
provider&.logo_url
if institution_domain.present? && Setting.brand_fetch_client_id.present?
logo_size = Setting.brand_fetch_logo_size
"https://cdn.brandfetch.io/#{institution_domain}/icon/fallback/lettermark/w/#{logo_size}/h/#{logo_size}?c=#{Setting.brand_fetch_client_id}"
elsif provider&.logo_url.present?
provider.logo_url
elsif logo.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo, only_path: true)
end
end
def destroy_later
@@ -299,6 +310,14 @@ class Account < ApplicationRecord
accountable_class.long_subtype_label_for(subtype) || accountable_class.display_name
end
def supports_default?
depository? || credit_card?
end
def eligible_for_transaction_default?
supports_default? && active? && !linked?
end
# Determines if this account supports manual trade entry
# Investment accounts always support trades; Crypto only if subtype is "exchange"
def supports_trades?

View File

@@ -8,7 +8,7 @@ class Account::Syncer
def perform_sync(sync)
Rails.logger.info("Processing balances (#{account.linked? ? 'reverse' : 'forward'})")
import_market_data
materialize_balances
materialize_balances(window_start_date: sync.window_start_date)
end
def perform_post_sync
@@ -16,9 +16,9 @@ class Account::Syncer
end
private
def materialize_balances
def materialize_balances(window_start_date: nil)
strategy = account.linked? ? :reverse : :forward
Balance::Materializer.new(account, strategy: strategy).materialize_balances
Balance::Materializer.new(account, strategy: strategy, window_start_date: window_start_date).materialize_balances
end
# Syncs all the exchange rates + security prices this account needs to display historical chart data

View File

@@ -0,0 +1,29 @@
class ArchivedExport < ApplicationRecord
has_one_attached :export_file, dependent: :purge_later
scope :expired, -> { where(expires_at: ...Time.current) }
attr_reader :download_token
before_create :set_download_token_digest
def downloadable?
expires_at > Time.current && export_file.attached?
end
def self.find_by_download_token!(token)
find_by!(download_token_digest: digest_token(token))
end
def self.digest_token(token)
OpenSSL::Digest::SHA256.hexdigest(token)
end
private
def set_download_token_digest
raw_token = SecureRandom.urlsafe_base64(24)
@download_token = raw_token
self.download_token_digest = self.class.digest_token(raw_token)
end
end

View File

@@ -1,101 +1,43 @@
class Assistant
include Provided, Configurable, Broadcastable
module Assistant
Error = Class.new(StandardError)
attr_reader :chat, :instructions
REGISTRY = {
"builtin" => Assistant::Builtin,
"external" => Assistant::External
}.freeze
class << self
def for_chat(chat)
config = config_for(chat)
new(chat, instructions: config[:instructions], functions: config[:functions])
implementation_for(chat).for_chat(chat)
end
def config_for(chat)
raise Error, "chat is required" if chat.blank?
Assistant::Builtin.config_for(chat)
end
def available_types
REGISTRY.keys
end
def function_classes
[
Function::GetTransactions,
Function::GetAccounts,
Function::GetHoldings,
Function::GetBalanceSheet,
Function::GetIncomeStatement,
Function::ImportBankStatement,
Function::SearchFamilyFiles
]
end
private
def implementation_for(chat)
raise Error, "chat is required" if chat.blank?
type = ENV["ASSISTANT_TYPE"].presence || chat.user&.family&.assistant_type.presence || "builtin"
REGISTRY.fetch(type) { REGISTRY["builtin"] }
end
end
def initialize(chat, instructions: nil, functions: [])
@chat = chat
@instructions = instructions
@functions = functions
end
def respond_to(message)
assistant_message = AssistantMessage.new(
chat: chat,
content: "",
ai_model: message.ai_model
)
llm_provider = get_model_provider(message.ai_model)
unless llm_provider
error_message = build_no_provider_error_message(message.ai_model)
raise StandardError, error_message
end
responder = Assistant::Responder.new(
message: message,
instructions: instructions,
function_tool_caller: function_tool_caller,
llm: llm_provider
)
latest_response_id = chat.latest_assistant_response_id
responder.on(:output_text) do |text|
if assistant_message.content.blank?
stop_thinking
Chat.transaction do
assistant_message.append_text!(text)
chat.update_latest_response!(latest_response_id)
end
else
assistant_message.append_text!(text)
end
end
responder.on(:response) do |data|
update_thinking("Analyzing your data...")
if data[:function_tool_calls].present?
assistant_message.tool_calls = data[:function_tool_calls]
latest_response_id = data[:id]
else
chat.update_latest_response!(data[:id])
end
end
responder.respond(previous_response_id: latest_response_id)
rescue => e
stop_thinking
chat.add_error(e)
end
private
attr_reader :functions
def function_tool_caller
function_instances = functions.map do |fn|
fn.new(chat.user)
end
@function_tool_caller ||= FunctionToolCaller.new(function_instances)
end
def build_no_provider_error_message(requested_model)
available_providers = registry.providers
if available_providers.empty?
"No LLM provider configured that supports model '#{requested_model}'. " \
"Please configure an LLM provider (e.g., OpenAI) in settings."
else
provider_details = available_providers.map do |provider|
" - #{provider.provider_name}: #{provider.supported_models_description}"
end.join("\n")
"No LLM provider configured that supports model '#{requested_model}'.\n\n" \
"Available providers:\n#{provider_details}\n\n" \
"Please either:\n" \
" 1. Use a supported model from the list above, or\n" \
" 2. Configure a provider that supports '#{requested_model}' in settings."
end
end
end

View File

@@ -0,0 +1,13 @@
class Assistant::Base
include Assistant::Broadcastable
attr_reader :chat
def initialize(chat)
@chat = chat
end
def respond_to(message)
raise NotImplementedError, "#{self.class}#respond_to must be implemented"
end
end

View File

@@ -0,0 +1,95 @@
class Assistant::Builtin < Assistant::Base
include Assistant::Provided
include Assistant::Configurable
attr_reader :instructions
class << self
def for_chat(chat)
config = config_for(chat)
new(chat, instructions: config[:instructions], functions: config[:functions])
end
end
def initialize(chat, instructions: nil, functions: [])
super(chat)
@instructions = instructions
@functions = functions
end
def respond_to(message)
assistant_message = AssistantMessage.new(
chat: chat,
content: "",
ai_model: message.ai_model
)
llm_provider = get_model_provider(message.ai_model)
unless llm_provider
raise StandardError, build_no_provider_error_message(message.ai_model)
end
responder = Assistant::Responder.new(
message: message,
instructions: instructions,
function_tool_caller: function_tool_caller,
llm: llm_provider
)
latest_response_id = chat.latest_assistant_response_id
responder.on(:output_text) do |text|
if assistant_message.content.blank?
stop_thinking
Chat.transaction do
assistant_message.append_text!(text)
chat.update_latest_response!(latest_response_id)
end
else
assistant_message.append_text!(text)
end
end
responder.on(:response) do |data|
update_thinking("Analyzing your data...")
if data[:function_tool_calls].present?
assistant_message.tool_calls = data[:function_tool_calls]
latest_response_id = data[:id]
else
chat.update_latest_response!(data[:id])
end
end
responder.respond(previous_response_id: latest_response_id)
rescue => e
stop_thinking
chat.add_error(e)
end
private
attr_reader :functions
def function_tool_caller
@function_tool_caller ||= Assistant::FunctionToolCaller.new(
functions.map { |fn| fn.new(chat.user) }
)
end
def build_no_provider_error_message(requested_model)
available_providers = registry.providers
if available_providers.empty?
"No LLM provider configured that supports model '#{requested_model}'. " \
"Please configure an LLM provider (e.g., OpenAI) in settings."
else
provider_details = available_providers.map do |provider|
" - #{provider.provider_name}: #{provider.supported_models_description}"
end.join("\n")
"No LLM provider configured that supports model '#{requested_model}'.\n\n" \
"Available providers:\n#{provider_details}\n\n" \
"Please either:\n" \
" 1. Use a supported model from the list above, or\n" \
" 2. Configure a provider that supports '#{requested_model}' in settings."
end
end
end

View File

@@ -52,15 +52,7 @@ module Assistant::Configurable
end
def default_functions
[
Assistant::Function::GetTransactions,
Assistant::Function::GetAccounts,
Assistant::Function::GetHoldings,
Assistant::Function::GetBalanceSheet,
Assistant::Function::GetIncomeStatement,
Assistant::Function::ImportBankStatement,
Assistant::Function::SearchFamilyFiles
]
Assistant.function_classes
end
def default_instructions(preferred_currency, preferred_date_format)

View File

@@ -0,0 +1,110 @@
class Assistant::External < Assistant::Base
Config = Struct.new(:url, :token, :agent_id, :session_key, keyword_init: true)
MAX_CONVERSATION_MESSAGES = 20
class << self
def for_chat(chat)
new(chat)
end
def configured?
config.url.present? && config.token.present?
end
def available_for?(user)
configured? && allowed_user?(user)
end
def allowed_user?(user)
allowed = ENV["EXTERNAL_ASSISTANT_ALLOWED_EMAILS"]
return true if allowed.blank?
return false if user&.email.blank?
allowed.split(",").map { |e| e.strip.downcase }.include?(user.email.downcase)
end
def config
Config.new(
url: ENV["EXTERNAL_ASSISTANT_URL"].presence || Setting.external_assistant_url.presence,
token: ENV["EXTERNAL_ASSISTANT_TOKEN"].presence || Setting.external_assistant_token.presence,
agent_id: ENV["EXTERNAL_ASSISTANT_AGENT_ID"].presence || Setting.external_assistant_agent_id.presence || "main",
session_key: ENV.fetch("EXTERNAL_ASSISTANT_SESSION_KEY", "agent:main:main")
)
end
end
def respond_to(message)
response_completed = false
unless self.class.configured?
raise Assistant::Error,
"External assistant is not configured. Set the URL and token in Settings > Self-Hosting or via environment variables."
end
unless self.class.allowed_user?(chat.user)
raise Assistant::Error, "Your account is not authorized to use the external assistant."
end
assistant_message = AssistantMessage.new(
chat: chat,
content: "",
ai_model: "external-agent"
)
client = build_client
messages = build_conversation_messages
model = client.chat(
messages: messages,
user: "sure-family-#{chat.user.family_id}"
) do |text|
if assistant_message.content.blank?
stop_thinking
assistant_message.content = text
assistant_message.save!
else
assistant_message.append_text!(text)
end
end
if assistant_message.new_record?
stop_thinking
raise Assistant::Error, "External assistant returned an empty response."
end
response_completed = true
assistant_message.update!(ai_model: model) if model.present?
rescue Assistant::Error, ActiveRecord::ActiveRecordError => e
cleanup_partial_response(assistant_message) unless response_completed
stop_thinking
chat.add_error(e)
rescue => e
Rails.logger.error("[Assistant::External] Unexpected error: #{e.class} - #{e.message}")
cleanup_partial_response(assistant_message) unless response_completed
stop_thinking
chat.add_error(Assistant::Error.new("Something went wrong with the external assistant. Check server logs for details."))
end
private
def cleanup_partial_response(assistant_message)
assistant_message&.destroy! if assistant_message&.persisted?
rescue ActiveRecord::ActiveRecordError => e
Rails.logger.warn("[Assistant::External] Failed to clean up partial response: #{e.message}")
end
def build_client
Assistant::External::Client.new(
url: self.class.config.url,
token: self.class.config.token,
agent_id: self.class.config.agent_id,
session_key: self.class.config.session_key
)
end
def build_conversation_messages
chat.conversation_messages.ordered.last(MAX_CONVERSATION_MESSAGES).map do |msg|
{ role: msg.role, content: msg.content }
end
end
end

175
app/models/assistant/external/client.rb vendored Normal file
View File

@@ -0,0 +1,175 @@
require "net/http"
require "uri"
require "json"
class Assistant::External::Client
TIMEOUT_CONNECT = 10 # seconds
TIMEOUT_READ = 120 # seconds (agent may take time to reason + call tools)
MAX_RETRIES = 2
RETRY_DELAY = 1 # seconds (doubles each retry)
MAX_SSE_BUFFER = 1_048_576 # 1 MB safety cap on SSE buffer
TRANSIENT_ERRORS = [
Net::OpenTimeout,
Net::ReadTimeout,
Errno::ECONNREFUSED,
Errno::ECONNRESET,
Errno::EHOSTUNREACH,
SocketError
].freeze
def initialize(url:, token:, agent_id: "main", session_key: "agent:main:main")
@url = url
@token = token # pipelock:ignore Credential in URL
@agent_id = agent_id
@session_key = session_key
end
# Streams text chunks from an OpenAI-compatible chat endpoint via SSE.
#
# messages - Array of {role:, content:} hashes (conversation history)
# user - Optional user identifier for session persistence
# block - Called with each text chunk as it arrives
#
# Returns the model identifier string from the response.
def chat(messages:, user: nil, &block)
uri = URI(@url)
request = build_request(uri, messages, user)
retries = 0
streaming_started = false
begin
http = build_http(uri)
model = stream_response(http, request) do |content|
streaming_started = true
block.call(content)
end
model
rescue *TRANSIENT_ERRORS => e
if streaming_started
Rails.logger.warn("[External::Client] Stream interrupted: #{e.class} - #{e.message}")
raise Assistant::Error, "External assistant connection was interrupted."
end
retries += 1
if retries <= MAX_RETRIES
Rails.logger.warn("[External::Client] Transient error (attempt #{retries}/#{MAX_RETRIES}): #{e.class} - #{e.message}")
sleep(RETRY_DELAY * retries)
retry
end
Rails.logger.error("[External::Client] Unreachable after #{MAX_RETRIES + 1} attempts: #{e.class} - #{e.message}")
raise Assistant::Error, "External assistant is temporarily unavailable."
end
end
private
def stream_response(http, request, &block)
model = nil
buffer = +""
done = false
http.request(request) do |response|
unless response.is_a?(Net::HTTPSuccess)
Rails.logger.warn("[External::Client] Upstream HTTP #{response.code}: #{response.body.to_s.truncate(500)}")
raise Assistant::Error, "External assistant returned HTTP #{response.code}."
end
response.read_body do |chunk|
break if done
buffer << chunk
if buffer.bytesize > MAX_SSE_BUFFER
raise Assistant::Error, "External assistant stream exceeded maximum buffer size."
end
while (line_end = buffer.index("\n"))
line = buffer.slice!(0..line_end).strip
next if line.empty?
next unless line.start_with?("data:")
data = line.delete_prefix("data:")
data = data.delete_prefix(" ") # SSE spec: strip one optional leading space
if data == "[DONE]"
done = true
break
end
parsed = parse_sse_data(data)
next unless parsed
model ||= parsed["model"]
content = parsed.dig("choices", 0, "delta", "content")
block.call(content) unless content.nil?
end
end
end
model
end
def build_http(uri)
proxy_uri = resolve_proxy(uri)
if proxy_uri
http = Net::HTTP.new(uri.host, uri.port, proxy_uri.host, proxy_uri.port, proxy_uri.user, proxy_uri.password)
else
http = Net::HTTP.new(uri.host, uri.port)
end
http.use_ssl = (uri.scheme == "https")
http.open_timeout = TIMEOUT_CONNECT
http.read_timeout = TIMEOUT_READ
http
end
def resolve_proxy(uri)
proxy_env = (uri.scheme == "https") ? "HTTPS_PROXY" : "HTTP_PROXY"
proxy_url = ENV[proxy_env] || ENV[proxy_env.downcase]
return nil if proxy_url.blank?
no_proxy = ENV["NO_PROXY"] || ENV["no_proxy"]
return nil if host_bypasses_proxy?(uri.host, no_proxy)
URI(proxy_url)
rescue URI::InvalidURIError => e
Rails.logger.warn("[External::Client] Invalid proxy URL ignored: #{e.message}")
nil
end
def host_bypasses_proxy?(host, no_proxy)
return false if no_proxy.blank?
host_down = host.downcase
no_proxy.split(",").any? do |pattern|
pattern = pattern.strip.downcase.delete_prefix(".")
host_down == pattern || host_down.end_with?(".#{pattern}")
end
end
def build_request(uri, messages, user)
request = Net::HTTP::Post.new(uri.request_uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{@token}"
request["Accept"] = "text/event-stream"
request["X-Agent-Id"] = @agent_id
request["X-Session-Key"] = @session_key
payload = {
model: @agent_id,
messages: messages,
stream: true
}
payload[:user] = user if user.present?
request.body = payload.to_json
request
end
def parse_sse_data(data)
JSON.parse(data)
rescue JSON::ParserError => e
Rails.logger.warn("[External::Client] Unparseable SSE data: #{e.message}")
nil
end
end

View File

@@ -53,7 +53,10 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function
query = params["query"]
max_results = (params["max_results"] || 10).to_i.clamp(1, 20)
Rails.logger.debug("[SearchFamilyFiles] query=#{query.inspect} max_results=#{max_results} family_id=#{family.id}")
unless family.vector_store_id.present?
Rails.logger.debug("[SearchFamilyFiles] family #{family.id} has no vector_store_id")
return {
success: false,
error: "no_documents",
@@ -64,6 +67,7 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function
adapter = VectorStore.adapter
unless adapter
Rails.logger.debug("[SearchFamilyFiles] no VectorStore adapter configured")
return {
success: false,
error: "provider_not_configured",
@@ -71,48 +75,95 @@ class Assistant::Function::SearchFamilyFiles < Assistant::Function
}
end
store_id = family.vector_store_id
Rails.logger.debug("[SearchFamilyFiles] searching store_id=#{store_id} via #{adapter.class.name}")
trace = create_langfuse_trace(
name: "search_family_files",
input: { query: query, max_results: max_results, store_id: store_id }
)
response = adapter.search(
store_id: family.vector_store_id,
store_id: store_id,
query: query,
max_results: max_results
)
unless response.success?
error_msg = response.error&.message
Rails.logger.debug("[SearchFamilyFiles] search failed: #{error_msg}")
begin
langfuse_client&.trace(id: trace.id, output: { error: error_msg }, level: "ERROR") if trace
rescue => e
Rails.logger.debug("[SearchFamilyFiles] Langfuse trace update failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
end
return {
success: false,
error: "search_failed",
message: "Failed to search documents: #{response.error&.message}"
message: "Failed to search documents: #{error_msg}"
}
end
results = response.data
if results.empty?
return {
success: true,
results: [],
message: "No matching documents found for the query."
}
Rails.logger.debug("[SearchFamilyFiles] #{results.size} chunk(s) returned")
results.each_with_index do |r, i|
Rails.logger.debug(
"[SearchFamilyFiles] chunk[#{i}] score=#{r[:score]} file=#{r[:filename].inspect} " \
"content_length=#{r[:content]&.length} preview=#{r[:content]&.truncate(10).inspect}"
)
end
{
success: true,
query: query,
result_count: results.size,
results: results.map do |result|
{
content: result[:content],
filename: result[:filename],
score: result[:score]
}
mapped = results.map do |result|
{ content: result[:content], filename: result[:filename], score: result[:score] }
end
output = if mapped.empty?
{ success: true, results: [], message: "No matching documents found for the query." }
else
{ success: true, query: query, result_count: mapped.size, results: mapped }
end
begin
if trace
langfuse_client&.trace(id: trace.id, output: {
result_count: mapped.size,
chunks: mapped.map { |r| { filename: r[:filename], score: r[:score], content_length: r[:content]&.length } }
})
end
}
rescue => e
Rails.logger.debug("[SearchFamilyFiles] Langfuse trace update failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
end
output
rescue => e
Rails.logger.error("SearchFamilyFiles error: #{e.class.name} - #{e.message}")
Rails.logger.error("[SearchFamilyFiles] error: #{e.class.name} - #{e.message}")
{
success: false,
error: "search_failed",
message: "An error occurred while searching documents: #{e.message.truncate(200)}"
}
end
private
def langfuse_client
return unless ENV["LANGFUSE_PUBLIC_KEY"].present? && ENV["LANGFUSE_SECRET_KEY"].present?
@langfuse_client ||= Langfuse.new
end
def create_langfuse_trace(name:, input:)
return unless langfuse_client
langfuse_client.trace(
name: name,
input: input,
user_id: user.id&.to_s,
environment: Rails.env
)
rescue => e
Rails.logger.debug("[SearchFamilyFiles] Langfuse trace creation failed: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}")
nil
end
end

View File

@@ -1,11 +1,22 @@
class Balance::ForwardCalculator < Balance::BaseCalculator
def initialize(account, window_start_date: nil)
super(account)
@window_start_date = window_start_date
@fell_back = nil # unknown until calculate is called
end
# True only when we are actually running in incremental mode (i.e. window_start_date
# was provided and we successfully found a valid prior balance to seed from).
#
# Must not be called before calculate — @fell_back is nil until resolve_starting_balances runs.
def incremental?
raise "incremental? must not be called before calculate" if @window_start_date.present? && @fell_back.nil?
@window_start_date.present? && @fell_back == false
end
def calculate
Rails.logger.tagged("Balance::ForwardCalculator") do
start_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: account.opening_anchor_date
)
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
start_cash_balance, start_non_cash_balance = resolve_starting_balances
calc_start_date.upto(calc_end_date).map do |date|
valuation = sync_cache.get_valuation(date)
@@ -52,8 +63,67 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
end
private
# Returns [start_cash_balance, start_non_cash_balance] for the first iteration.
#
# In incremental mode: load the persisted end-of-day balance for window_start_date - 1
# from the DB and use that as the seed. Falls back to full recalculation when:
# - No prior balance record exists in the DB, or
# - The prior balance has a non-zero non-cash component (e.g. investment holdings)
# because Holding::Materializer always does a full recalc, which could make the
# persisted non-cash seed stale relative to freshly-computed holding prices.
def resolve_starting_balances
if @window_start_date.present?
if multi_currency_account?
Rails.logger.info("Account has multi-currency entries or is foreign, falling back to full recalculation")
@fell_back = true
return opening_starting_balances
end
prior = prior_balance
if prior && (prior.end_non_cash_balance || 0).zero?
Rails.logger.info("Incremental sync from #{@window_start_date}, seeding from persisted balance on #{prior.date}")
@fell_back = false
return [ prior.end_cash_balance, prior.end_non_cash_balance ]
elsif prior
Rails.logger.info("Prior balance has non-cash component, falling back to full recalculation")
else
Rails.logger.info("No persisted balance found for #{@window_start_date - 1}, falling back to full recalculation")
end
@fell_back = true
end
opening_starting_balances
end
# Returns true when the account has entries in currencies other than the
# account currency, or when the account currency differs from the family
# currency. In either case, balance calculations depend on exchange rates
# that may have been missing (fallback_rate: 1) on a prior sync and later
# imported — so we must do a full recalculation to pick them up.
def multi_currency_account?
account.entries.where.not(currency: account.currency).exists? ||
account.currency != account.family.currency
end
def opening_starting_balances
cash = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
date: account.opening_anchor_date
)
[ cash, account.opening_anchor_balance - cash ]
end
# The balance record for the day immediately before the incremental window.
def prior_balance
account.balances
.where(currency: account.currency)
.find_by(date: @window_start_date - 1)
end
def calc_start_date
account.opening_anchor_date
incremental? ? @window_start_date : account.opening_anchor_date
end
def calc_end_date

View File

@@ -1,9 +1,11 @@
class Balance::Materializer
attr_reader :account, :strategy
attr_reader :account, :strategy, :security_ids
def initialize(account, strategy:)
def initialize(account, strategy:, security_ids: nil, window_start_date: nil)
@account = account
@strategy = strategy
@security_ids = security_ids
@window_start_date = window_start_date
end
def materialize_balances
@@ -24,7 +26,7 @@ class Balance::Materializer
private
def materialize_holdings
@holdings = Holding::Materializer.new(account, strategy: strategy).materialize_holdings
@holdings = Holding::Materializer.new(account, strategy: strategy, security_ids: security_ids).materialize_holdings
end
def update_account_info
@@ -73,17 +75,44 @@ class Balance::Materializer
def purge_stale_balances
sorted_balances = @balances.sort_by(&:date)
oldest_calculated_balance_date = sorted_balances.first&.date
newest_calculated_balance_date = sorted_balances.last&.date
deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_calculated_balance_date, newest_calculated_balance_date)
if sorted_balances.empty?
# In incremental forward-sync, even when no balances were calculated for the window
# (e.g. window_start_date is beyond the last entry), purge stale tail records that
# now fall beyond the prior-balance boundary so orphaned future rows are cleaned up.
if strategy == :forward && calculator.incremental? && account.opening_anchor_date <= @window_start_date - 1
deleted_count = account.balances.delete_by(
"date < ? OR date > ?",
account.opening_anchor_date,
@window_start_date - 1
)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end
return
end
newest_calculated_balance_date = sorted_balances.last.date
# In incremental forward-sync mode the calculator only recalculates from
# window_start_date onward, so balances before that date are still valid.
# Use opening_anchor_date as the lower purge bound to preserve them.
# We ask the calculator whether it actually ran incrementally — it may have
# fallen back to a full recalculation, in which case we use the normal bound.
oldest_valid_date = if strategy == :forward && calculator.incremental?
account.opening_anchor_date
else
sorted_balances.first.date
end
deleted_count = account.balances.delete_by("date < ? OR date > ?", oldest_valid_date, newest_calculated_balance_date)
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
end
def calculator
if strategy == :reverse
@calculator ||= if strategy == :reverse
Balance::ReverseCalculator.new(account)
else
Balance::ForwardCalculator.new(account)
Balance::ForwardCalculator.new(account, window_start_date: @window_start_date)
end
end
end

View File

@@ -81,7 +81,7 @@ class Budget < ApplicationRecord
end
def sync_budget_categories
current_category_ids = family.categories.expenses.pluck(:id).to_set
current_category_ids = family.categories.pluck(:id).to_set
existing_budget_category_ids = budget_categories.pluck(:category_id).to_set
categories_to_add = current_category_ids - existing_budget_category_ids
categories_to_remove = existing_budget_category_ids - current_category_ids
@@ -126,12 +126,42 @@ class Budget < ApplicationRecord
budgeted_spending.present?
end
def most_recent_initialized_budget
family.budgets
.includes(:budget_categories)
.where("start_date < ?", start_date)
.where.not(budgeted_spending: nil)
.order(start_date: :desc)
.first
end
def copy_from!(source_budget)
raise ArgumentError, "source budget must belong to the same family" unless source_budget.family_id == family_id
raise ArgumentError, "source budget must precede target budget" unless source_budget.start_date < start_date
Budget.transaction do
update!(
budgeted_spending: source_budget.budgeted_spending,
expected_income: source_budget.expected_income
)
target_by_category = budget_categories.index_by(&:category_id)
source_budget.budget_categories.each do |source_bc|
target_bc = target_by_category[source_bc.category_id]
next unless target_bc
target_bc.update!(budgeted_spending: source_bc.budgeted_spending)
end
end
end
def income_category_totals
income_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
net_totals.net_income_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse
end
def expense_category_totals
expense_totals.category_totals.reject { |ct| ct.category.subcategory? || ct.total.zero? }.sort_by(&:weight).reverse
net_totals.net_expense_categories.reject { |ct| ct.total.zero? }.sort_by(&:weight).reverse
end
def current?
@@ -184,13 +214,13 @@ class Budget < ApplicationRecord
end
def actual_spending
[ expense_totals.total - refunds_in_expense_categories, 0 ].max
net_totals.total_net_expense
end
def budget_category_actual_spending(budget_category)
cat_id = budget_category.category_id
expense = expense_totals_by_category[cat_id]&.total || 0
refund = income_totals_by_category[cat_id]&.total || 0
key = budget_category.category_id || stable_synthetic_key(budget_category.category)
expense = expense_totals_by_category[key]&.total || 0
refund = income_totals_by_category[key]&.total || 0
[ expense - refund, 0 ].max
end
@@ -267,31 +297,35 @@ class Budget < ApplicationRecord
end
private
def refunds_in_expense_categories
expense_category_ids = budget_categories.map(&:category_id).to_set
income_totals.category_totals
.reject { |ct| ct.category.subcategory? }
.select { |ct| expense_category_ids.include?(ct.category.id) || ct.category.uncategorized? }
.sum(&:total)
end
def income_statement
@income_statement ||= family.income_statement
end
def net_totals
@net_totals ||= income_statement.net_category_totals(period: period)
end
def expense_totals
@expense_totals ||= income_statement.expense_totals(period: period)
end
def income_totals
@income_totals ||= family.income_statement.income_totals(period: period)
@income_totals ||= income_statement.income_totals(period: period)
end
def expense_totals_by_category
@expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id }
@expense_totals_by_category ||= expense_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) }
end
def income_totals_by_category
@income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id }
@income_totals_by_category ||= income_totals.category_totals.index_by { |ct| ct.category.id || stable_synthetic_key(ct.category) }
end
def stable_synthetic_key(category)
if category.uncategorized?
:uncategorized
elsif category.other_investments?
:other_investments
end
end
end

View File

@@ -209,6 +209,7 @@ class BudgetCategory < ApplicationRecord
def subcategories
return BudgetCategory.none unless category.parent_id.nil?
return BudgetCategory.none if category.id.nil?
budget.budget_categories
.joins(:category)

View File

@@ -12,7 +12,6 @@ class Category < ApplicationRecord
validates :name, uniqueness: { scope: :family_id }
validate :category_level_limit
validate :nested_category_matches_parent_classification
before_save :inherit_color_from_parent
@@ -24,8 +23,9 @@ class Category < ApplicationRecord
.order(:name)
}
scope :roots, -> { where(parent_id: nil) }
scope :incomes, -> { where(classification: "income") }
scope :expenses, -> { where(classification: "expense") }
# Legacy scopes - classification removed; these now return all categories
scope :incomes, -> { all }
scope :expenses, -> { all }
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
@@ -35,6 +35,55 @@ class Category < ApplicationRecord
PAYMENT_COLOR = "#db5a54"
TRADE_COLOR = "#e99537"
ICON_KEYWORDS = {
/income|salary|paycheck|wage|earning/ => "circle-dollar-sign",
/groceries|grocery|supermarket/ => "shopping-bag",
/food|dining|restaurant|meal|lunch|dinner|breakfast/ => "utensils",
/coffee|cafe|café/ => "coffee",
/shopping|retail/ => "shopping-cart",
/transport|transit|commute|subway|metro/ => "bus",
/parking/ => "circle-parking",
/car|auto|vehicle/ => "car",
/gas|fuel|petrol/ => "fuel",
/flight|airline/ => "plane",
/travel|trip|vacation|holiday/ => "plane",
/hotel|lodging|accommodation/ => "hotel",
/movie|cinema|film|theater|theatre/ => "film",
/music|concert/ => "music",
/game|gaming/ => "gamepad-2",
/entertainment|leisure/ => "drama",
/sport|fitness|gym|workout|exercise/ => "dumbbell",
/pharmacy|drug|medicine|pill|medication|dental|dentist/ => "pill",
/health|medical|clinic|doctor|physician/ => "stethoscope",
/personal care|beauty|salon|spa|hair/ => "scissors",
/mortgage|rent/ => "home",
/home|house|apartment|housing/ => "home",
/improvement|renovation|remodel/ => "hammer",
/repair|maintenance/ => "wrench",
/electric|power|energy/ => "zap",
/water|sewage/ => "waves",
/internet|cable|broadband|subscription|streaming/ => "wifi",
/utilities|utility/ => "lightbulb",
/phone|telephone/ => "phone",
/mobile|cell/ => "smartphone",
/insurance/ => "shield",
/gift|present/ => "gift",
/donat|charity|nonprofit/ => "hand-helping",
/tax|irs|revenue/ => "landmark",
/loan|debt|credit card/ => "credit-card",
/service|professional/ => "briefcase",
/fee|charge/ => "receipt",
/bank|banking/ => "landmark",
/saving/ => "piggy-bank",
/invest|stock|fund|portfolio/ => "trending-up",
/pet|dog|cat|animal|vet/ => "paw-print",
/education|school|university|college|tuition/ => "graduation-cap",
/book|reading|library/ => "book",
/child|kid|baby|infant|daycare/ => "baby",
/cloth|apparel|fashion|wear/ => "shirt",
/ticket/ => "ticket"
}.freeze
# Category name keys for i18n
UNCATEGORIZED_NAME_KEY = "models.category.uncategorized"
OTHER_INVESTMENTS_NAME_KEY = "models.category.other_investments"
@@ -58,6 +107,16 @@ class Category < ApplicationRecord
end
class << self
def suggested_icon(name)
name_down = name.to_s.downcase
ICON_KEYWORDS.each do |pattern, icon|
return icon if name_down.match?(pattern)
end
"shapes"
end
def icon_codes
%w[
ambulance apple award baby badge-dollar-sign banknote barcode bar-chart-3 bath
@@ -79,10 +138,9 @@ class Category < ApplicationRecord
end
def bootstrap!
default_categories.each do |name, color, icon, classification|
default_categories.each do |name, color, icon|
find_or_create_by!(name: name) do |category|
category.color = color
category.classification = classification
category.lucide_icon = icon
end
end
@@ -138,28 +196,28 @@ class Category < ApplicationRecord
private
def default_categories
[
[ "Income", "#22c55e", "circle-dollar-sign", "income" ],
[ "Food & Drink", "#f97316", "utensils", "expense" ],
[ "Groceries", "#407706", "shopping-bag", "expense" ],
[ "Shopping", "#3b82f6", "shopping-cart", "expense" ],
[ "Transportation", "#0ea5e9", "bus", "expense" ],
[ "Travel", "#2563eb", "plane", "expense" ],
[ "Entertainment", "#a855f7", "drama", "expense" ],
[ "Healthcare", "#4da568", "pill", "expense" ],
[ "Personal Care", "#14b8a6", "scissors", "expense" ],
[ "Home Improvement", "#d97706", "hammer", "expense" ],
[ "Mortgage / Rent", "#b45309", "home", "expense" ],
[ "Utilities", "#eab308", "lightbulb", "expense" ],
[ "Subscriptions", "#6366f1", "wifi", "expense" ],
[ "Insurance", "#0284c7", "shield", "expense" ],
[ "Sports & Fitness", "#10b981", "dumbbell", "expense" ],
[ "Gifts & Donations", "#61c9ea", "hand-helping", "expense" ],
[ "Taxes", "#dc2626", "landmark", "expense" ],
[ "Loan Payments", "#e11d48", "credit-card", "expense" ],
[ "Services", "#7c3aed", "briefcase", "expense" ],
[ "Fees", "#6b7280", "receipt", "expense" ],
[ "Savings & Investments", "#059669", "piggy-bank", "expense" ],
[ investment_contributions_name, "#0d9488", "trending-up", "expense" ]
[ "Income", "#22c55e", "circle-dollar-sign" ],
[ "Food & Drink", "#f97316", "utensils" ],
[ "Groceries", "#407706", "shopping-bag" ],
[ "Shopping", "#3b82f6", "shopping-cart" ],
[ "Transportation", "#0ea5e9", "bus" ],
[ "Travel", "#2563eb", "plane" ],
[ "Entertainment", "#a855f7", "drama" ],
[ "Healthcare", "#4da568", "pill" ],
[ "Personal Care", "#14b8a6", "scissors" ],
[ "Home Improvement", "#d97706", "hammer" ],
[ "Mortgage / Rent", "#b45309", "home" ],
[ "Utilities", "#eab308", "lightbulb" ],
[ "Subscriptions", "#6366f1", "wifi" ],
[ "Insurance", "#0284c7", "shield" ],
[ "Sports & Fitness", "#10b981", "dumbbell" ],
[ "Gifts & Donations", "#61c9ea", "hand-helping" ],
[ "Taxes", "#dc2626", "landmark" ],
[ "Loan Payments", "#e11d48", "credit-card" ],
[ "Services", "#7c3aed", "briefcase" ],
[ "Fees", "#6b7280", "receipt" ],
[ "Savings & Investments", "#059669", "piggy-bank" ],
[ investment_contributions_name, "#0d9488", "trending-up" ]
]
end
end
@@ -211,12 +269,6 @@ class Category < ApplicationRecord
end
end
def nested_category_matches_parent_classification
if subcategory? && parent.classification != classification
errors.add(:parent, "must have the same classification as its parent")
end
end
def monetizable_currency
family.currency
end

View File

@@ -5,7 +5,6 @@ class CategoryImport < Import
category_name = row.name.to_s.strip
category = family.categories.find_or_initialize_by(name: category_name)
category.color = row.category_color.presence || category.color || Category::UNCATEGORIZED_COLOR
category.classification = row.category_classification.presence || category.classification || "expense"
category.lucide_icon = row.category_icon.presence || category.lucide_icon || "shapes"
category.parent = nil
category.save!
@@ -30,7 +29,7 @@ class CategoryImport < Import
end
def column_keys
%i[name category_color category_parent category_classification category_icon]
%i[name category_color category_parent category_icon]
end
def required_column_keys
@@ -47,10 +46,10 @@ class CategoryImport < Import
def csv_template
template = <<-CSV
name*,color,parent_category,classification,lucide_icon
Food & Drink,#f97316,,expense,carrot
Groceries,#407706,Food & Drink,expense,shopping-basket
Salary,#22c55e,,income,briefcase
name*,color,parent_category,lucide_icon
Food & Drink,#f97316,,carrot
Groceries,#407706,Food & Drink,shopping-basket
Salary,#22c55e,,briefcase
CSV
CSV.parse(template, headers: true)
@@ -64,7 +63,6 @@ class CategoryImport < Import
name_header = header_for("name")
color_header = header_for("color")
parent_header = header_for("parent_category", "parent category")
classification_header = header_for("classification")
icon_header = header_for("lucide_icon", "lucide icon", "icon")
csv_rows.each do |row|
@@ -72,7 +70,6 @@ class CategoryImport < Import
name: row[name_header].to_s.strip,
category_color: row[color_header].to_s.strip,
category_parent: row[parent_header].to_s.strip,
category_classification: row[classification_header].to_s.strip,
category_icon: row[icon_header].to_s.strip,
currency: default_currency
)
@@ -112,7 +109,6 @@ class CategoryImport < Import
family.categories.find_or_create_by!(name: trimmed_name) do |placeholder|
placeholder.color = Category::UNCATEGORIZED_COLOR
placeholder.classification = "expense"
placeholder.lucide_icon = "shapes"
end
end

View File

@@ -75,10 +75,6 @@ class Chat < ApplicationRecord
end
def conversation_messages
if debug_mode?
messages
else
messages.where(type: [ "UserMessage", "AssistantMessage" ])
end
messages.where(type: [ "UserMessage", "AssistantMessage" ])
end
end

View File

@@ -15,6 +15,7 @@ class CoinbaseAccount < ApplicationRecord
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
validates :account_id, uniqueness: { scope: :coinbase_item_id, allow_nil: true }
# Helper to get account using account_providers system
def current_account

View File

@@ -0,0 +1,428 @@
# Parses QIF (Quicken Interchange Format) files.
#
# A QIF file is a plain-text format exported by Quicken. It is divided into
# sections, each introduced by a "!Type:<name>" header line. Records within
# a section are terminated by a "^" line. Each data line starts with a single
# letter field code followed immediately by the value.
#
# Sections handled:
# !Type:Tag tag definitions (N=name, D=description)
# !Type:Cat category definitions (N=name, D=description, I=income, E=expense)
# !Type:Security security definitions (N=name, S=ticker, T=type)
# !Type:CCard / !Type:Bank / !Type:Cash / !Type:Oth L transactions
# !Type:Invst investment transactions
#
# Transaction field codes:
# D date M/ D'YY or MM/DD'YYYY
# T amount may include commas, e.g. "-1,234.56"
# U amount same as T (alternate field)
# P payee
# M memo
# L category plain name or [TransferAccount]; /Tag suffix is supported
# N check/ref (not a tag the check number or reference)
# C cleared X = cleared, * = reconciled
# ^ end of record
#
# Investment-specific field codes (in !Type:Invst records):
# N action Buy, Sell, Div, XIn, XOut, IntInc, CGLong, CGShort, etc.
# Y security security name (matches N field in !Type:Security)
# I price price per share
# Q quantity number of shares
# T total total cash amount of transaction
module QifParser
TRANSACTION_TYPES = %w[CCard Bank Cash Invst Oth\ L Oth\ A].freeze
# Investment action types that create Trade records (buy or sell shares).
BUY_LIKE_ACTIONS = %w[Buy ReinvDiv Cover].freeze
SELL_LIKE_ACTIONS = %w[Sell ShtSell].freeze
TRADE_ACTIONS = (BUY_LIKE_ACTIONS + SELL_LIKE_ACTIONS).freeze
# Investment action types that create Transaction records.
INFLOW_TRANSACTION_ACTIONS = %w[Div IntInc XIn CGLong CGShort MiscInc].freeze
OUTFLOW_TRANSACTION_ACTIONS = %w[XOut MiscExp].freeze
ParsedTransaction = Struct.new(
:date, :amount, :payee, :memo, :category, :tags, :check_num, :cleared, :split,
keyword_init: true
)
ParsedCategory = Struct.new(:name, :description, :income, keyword_init: true)
ParsedTag = Struct.new(:name, :description, keyword_init: true)
ParsedSecurity = Struct.new(:name, :ticker, :security_type, keyword_init: true)
ParsedInvestmentTransaction = Struct.new(
:date, :action, :security_name, :security_ticker,
:price, :qty, :amount, :memo, :payee, :category, :tags,
keyword_init: true
)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
# Transcodes raw file bytes to UTF-8.
# Quicken on Windows writes QIF files in a Windows code page that varies by region:
# Windows-1252 North America, Western Europe
# Windows-1250 Central/Eastern Europe (Poland, Czech Republic, Hungary, …)
#
# We try each encoding with undef: :raise so we only accept an encoding when
# every byte in the file is defined in that code page. Windows-1252 has five
# undefined byte values (0x81, 0x8D, 0x8F, 0x90, 0x9D); if any are present we
# fall through to Windows-1250 which covers those slots differently.
FALLBACK_ENCODINGS = %w[Windows-1252 Windows-1250].freeze
def self.normalize_encoding(content)
return content if content.nil?
binary = content.b # Force ASCII-8BIT; never raises on invalid bytes
utf8_attempt = binary.dup.force_encoding("UTF-8")
return utf8_attempt if utf8_attempt.valid_encoding?
FALLBACK_ENCODINGS.each do |encoding|
begin
return binary.encode("UTF-8", encoding)
rescue Encoding::UndefinedConversionError
next
end
end
# Last resort: replace any remaining undefined bytes rather than raise
binary.encode("UTF-8", "Windows-1252", invalid: :replace, undef: :replace, replace: "")
end
# Returns true if the content looks like a valid QIF file.
def self.valid?(content)
return false if content.blank?
binary = content.b
binary.include?("!Type:")
end
# Returns the transaction account type string (e.g. "CCard", "Bank", "Invst").
# Skips metadata sections (Tag, Cat, Security, Prices) which are not account data.
def self.account_type(content)
return nil if content.blank?
content.scan(/^!Type:(.+)/i).flatten
.map(&:strip)
.reject { |t| %w[Tag Cat Security Prices].include?(t) }
.first
end
# Parses all transactions from the file, excluding the Opening Balance entry.
# Returns an array of ParsedTransaction structs.
def self.parse(content)
return [] unless valid?(content)
content = normalize_encoding(content)
content = normalize_line_endings(content)
type = account_type(content)
return [] unless type
section = extract_section(content, type)
return [] unless section
parse_records(section).filter_map { |record| build_transaction(record) }
end
# Returns the opening balance entry from the QIF file, if present.
# In Quicken's QIF format, the first transaction of a bank/cash account is often
# an "Opening Balance" record with payee "Opening Balance". This entry is NOT a
# real transaction it is the account's starting balance.
#
# Returns a hash { date: Date, amount: BigDecimal } or nil.
def self.parse_opening_balance(content)
return nil unless valid?(content)
content = normalize_encoding(content)
content = normalize_line_endings(content)
type = account_type(content)
return nil unless type
section = extract_section(content, type)
return nil unless section
record = parse_records(section).find { |r| r["P"]&.strip == "Opening Balance" }
return nil unless record
date = parse_qif_date(record["D"])
amount = parse_qif_amount(record["T"] || record["U"])
return nil unless date && amount
{ date: Date.parse(date), amount: amount.to_d }
end
# Parses categories from the !Type:Cat section.
# Returns an array of ParsedCategory structs.
def self.parse_categories(content)
return [] if content.blank?
content = normalize_encoding(content)
content = normalize_line_endings(content)
section = extract_section(content, "Cat")
return [] unless section
parse_records(section).filter_map do |record|
next unless record["N"].present?
ParsedCategory.new(
name: record["N"],
description: record["D"],
income: record.key?("I") && !record.key?("E")
)
end
end
# Parses tags from the !Type:Tag section.
# Returns an array of ParsedTag structs.
def self.parse_tags(content)
return [] if content.blank?
content = normalize_encoding(content)
content = normalize_line_endings(content)
section = extract_section(content, "Tag")
return [] unless section
parse_records(section).filter_map do |record|
next unless record["N"].present?
ParsedTag.new(
name: record["N"],
description: record["D"]
)
end
end
# Parses all !Type:Security sections and returns an array of ParsedSecurity structs.
# Each security in a QIF file gets its own !Type:Security header, so we scan
# for all occurrences rather than just the first.
def self.parse_securities(content)
return [] if content.blank?
content = normalize_encoding(content)
content = normalize_line_endings(content)
securities = []
content.scan(/^!Type:Security[^\n]*\n(.*?)(?=^!Type:|\z)/mi) do |captures|
parse_records(captures[0]).each do |record|
next unless record["N"].present? && record["S"].present?
securities << ParsedSecurity.new(
name: record["N"].strip,
ticker: record["S"].strip,
security_type: record["T"]&.strip
)
end
end
securities
end
# Parses investment transactions from the !Type:Invst section.
# Uses the !Type:Security sections to resolve security names to tickers.
# Returns an array of ParsedInvestmentTransaction structs.
def self.parse_investment_transactions(content)
return [] unless valid?(content)
content = normalize_encoding(content)
content = normalize_line_endings(content)
ticker_by_name = parse_securities(content).each_with_object({}) { |s, h| h[s.name] = s.ticker }
section = extract_section(content, "Invst")
return [] unless section
parse_records(section).filter_map { |record| build_investment_transaction(record, ticker_by_name) }
end
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def self.normalize_line_endings(content)
content.gsub(/\r\n/, "\n").gsub(/\r/, "\n")
end
private_class_method :normalize_line_endings
# Extracts the raw text of a named section (everything after its !Type: header
# up to the next !Type: header or end-of-file).
def self.extract_section(content, type_name)
escaped = Regexp.escape(type_name)
pattern = /^!Type:#{escaped}[^\n]*\n(.*?)(?=^!Type:|\z)/mi
content.match(pattern)&.captures&.first
end
private_class_method :extract_section
# Splits a section into an array of field-code => value hashes.
# Single-letter codes with no value (e.g. "I", "E", "T") are stored with nil.
# Split transactions (multiple S/$/E lines) are flagged with "_split" => true.
def self.parse_records(section_content)
records = []
current = {}
section_content.each_line do |line|
line = line.chomp
next if line.blank?
if line == "^"
records << current unless current.empty?
current = {}
else
code = line[0]
value = line[1..]&.strip
next unless code
# Mark records that contain split fields (S = split category, $ = split amount)
current["_split"] = true if code == "S"
# Flag fields like "I" (income) and "E" (expense) have no meaningful value
current[code] = value.presence
end
end
records << current unless current.empty?
records
end
private_class_method :parse_records
def self.build_transaction(record)
# "Opening Balance" is a Quicken convention for the account's starting balance
# it is not a real transaction and must not be imported as one.
return nil if record["P"]&.strip == "Opening Balance"
raw_date = record["D"]
raw_amount = record["T"] || record["U"]
return nil unless raw_date.present? && raw_amount.present?
date = parse_qif_date(raw_date)
amount = parse_qif_amount(raw_amount)
return nil unless date && amount
category, tags = parse_category_and_tags(record["L"])
ParsedTransaction.new(
date: date,
amount: amount,
payee: record["P"],
memo: record["M"],
category: category,
tags: tags,
check_num: record["N"],
cleared: record["C"],
split: record["_split"] == true
)
end
private_class_method :build_transaction
# Separates the category name from any tag(s) appended with a "/" delimiter.
# Transfer accounts are wrapped in brackets treated as no category.
#
# Examples:
# "Food & Dining" → ["Food & Dining", []]
# "Food & Dining/EUROPE2025" → ["Food & Dining", ["EUROPE2025"]]
# "[TD - Chequing]" → ["", []]
def self.parse_category_and_tags(l_field)
return [ "", [] ] if l_field.blank?
# Transfer account reference
return [ "", [] ] if l_field.start_with?("[")
# Quicken uses "--Split--" as a placeholder category for split transactions
return [ "", [] ] if l_field.strip.match?(/\A--Split--\z/i)
parts = l_field.split("/", 2)
category = parts[0].strip
tags = parts[1].present? ? parts[1].split(":").map(&:strip).reject(&:blank?) : []
[ category, tags ]
end
private_class_method :parse_category_and_tags
# Parses a QIF date string into an ISO 8601 date string.
#
# Quicken uses several variants:
# M/D'YY → 6/ 4'20 → 2020-06-04
# M/ D'YY → 6/ 4'20 → 2020-06-04
# MM/DD/YYYY → 06/04/2020 (less common)
def self.parse_qif_date(date_str)
return nil if date_str.blank?
# Primary format: M/D'YY or M/ D'YY (spaces around day are optional)
if (m = date_str.match(%r{\A(\d{1,2})/\s*(\d{1,2})'(\d{2,4})\z}))
month = m[1].to_i
day = m[2].to_i
if m[3].length == 2
year = 2000 + m[3].to_i
year -= 100 if year > Date.today.year
else
year = m[3].to_i
end
return Date.new(year, month, day).iso8601
end
# Fallback: MM/DD/YYYY
if (m = date_str.match(%r{\A(\d{1,2})/(\d{1,2})/(\d{4})\z}))
return Date.new(m[3].to_i, m[1].to_i, m[2].to_i).iso8601
end
nil
rescue Date::Error, ArgumentError
nil
end
private_class_method :parse_qif_date
# Strips thousands-separator commas and returns a clean decimal string.
def self.parse_qif_amount(amount_str)
return nil if amount_str.blank?
cleaned = amount_str.gsub(",", "").strip
cleaned =~ /\A-?\d+\.?\d*\z/ ? cleaned : nil
end
private_class_method :parse_qif_amount
# Builds a ParsedInvestmentTransaction from a raw record hash.
# ticker_by_name maps security names (N field in !Type:Security) to tickers (S field).
def self.build_investment_transaction(record, ticker_by_name)
action = record["N"]&.strip
return nil unless action.present?
raw_date = record["D"]
return nil unless raw_date.present?
date = parse_qif_date(raw_date)
return nil unless date
security_name = record["Y"]&.strip
security_ticker = ticker_by_name[security_name] || security_name
price = parse_qif_amount(record["I"])
qty = parse_qif_amount(record["Q"])
amount = parse_qif_amount(record["T"] || record["U"])
category, tags = parse_category_and_tags(record["L"])
ParsedInvestmentTransaction.new(
date: date,
action: action,
security_name: security_name,
security_ticker: security_ticker,
price: price,
qty: qty,
amount: amount,
memo: record["M"]&.strip,
payee: record["P"]&.strip,
category: category,
tags: tags
)
end
private_class_method :build_investment_transaction
end

View File

@@ -213,36 +213,36 @@ class Demo::Generator
def create_realistic_categories!(family)
# Income categories (3 total)
@salary_cat = family.categories.create!(name: "Salary", color: "#10b981", classification: "income")
@freelance_cat = family.categories.create!(name: "Freelance", color: "#059669", classification: "income")
@investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857", classification: "income")
@salary_cat = family.categories.create!(name: "Salary", color: "#10b981")
@freelance_cat = family.categories.create!(name: "Freelance", color: "#059669")
@investment_income_cat = family.categories.create!(name: "Investment Income", color: "#047857")
# Expense categories with subcategories (12 total)
@housing_cat = family.categories.create!(name: "Housing", color: "#dc2626", classification: "expense")
@rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c", classification: "expense")
@utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b", classification: "expense")
@housing_cat = family.categories.create!(name: "Housing", color: "#dc2626")
@rent_cat = family.categories.create!(name: "Rent/Mortgage", parent: @housing_cat, color: "#b91c1c")
@utilities_cat = family.categories.create!(name: "Utilities", parent: @housing_cat, color: "#991b1b")
@food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c", classification: "expense")
@groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c", classification: "expense")
@restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412", classification: "expense")
@coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12", classification: "expense")
@food_cat = family.categories.create!(name: "Food & Dining", color: "#ea580c")
@groceries_cat = family.categories.create!(name: "Groceries", parent: @food_cat, color: "#c2410c")
@restaurants_cat = family.categories.create!(name: "Restaurants", parent: @food_cat, color: "#9a3412")
@coffee_cat = family.categories.create!(name: "Coffee & Takeout", parent: @food_cat, color: "#7c2d12")
@transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb", classification: "expense")
@gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8", classification: "expense")
@car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af", classification: "expense")
@transportation_cat = family.categories.create!(name: "Transportation", color: "#2563eb")
@gas_cat = family.categories.create!(name: "Gas", parent: @transportation_cat, color: "#1d4ed8")
@car_payment_cat = family.categories.create!(name: "Car Payment", parent: @transportation_cat, color: "#1e40af")
@entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed", classification: "expense")
@healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777", classification: "expense")
@shopping_cat = family.categories.create!(name: "Shopping", color: "#059669", classification: "expense")
@travel_cat = family.categories.create!(name: "Travel", color: "#0891b2", classification: "expense")
@personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d", classification: "expense")
@entertainment_cat = family.categories.create!(name: "Entertainment", color: "#7c3aed")
@healthcare_cat = family.categories.create!(name: "Healthcare", color: "#db2777")
@shopping_cat = family.categories.create!(name: "Shopping", color: "#059669")
@travel_cat = family.categories.create!(name: "Travel", color: "#0891b2")
@personal_care_cat = family.categories.create!(name: "Personal Care", color: "#be185d")
# Additional high-level expense categories to reach 13 top-level items
@insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1", classification: "expense")
@misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280", classification: "expense")
@insurance_cat = family.categories.create!(name: "Insurance", color: "#6366f1")
@misc_cat = family.categories.create!(name: "Miscellaneous", color: "#6b7280")
# Interest expense bucket
@interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569", classification: "expense")
@interest_cat = family.categories.create!(name: "Loan Interest", color: "#475569")
end
def create_realistic_accounts!(family)
@@ -354,11 +354,11 @@ class Demo::Generator
analysis_start = (current_month - 3.months).beginning_of_month
analysis_period = analysis_start..(current_month - 1.day)
# Fetch expense transactions in the analysis period
# Fetch expense transactions in the analysis period (positive amounts = expenses)
txns = Entry.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id")
.joins("INNER JOIN categories ON categories.id = transactions.category_id")
.where(entries: { entryable_type: "Transaction", date: analysis_period })
.where(categories: { classification: "expense" })
.where("entries.amount > 0")
spend_per_cat = txns.group("categories.id").sum("entries.amount")

View File

@@ -1,10 +0,0 @@
class DeveloperMessage < Message
def role
"developer"
end
private
def broadcast?
chat.debug_mode?
end
end

View File

@@ -16,6 +16,7 @@ class EnableBankingAccount < ApplicationRecord
validates :name, :currency, presence: true
validates :uid, presence: true, uniqueness: { scope: :enable_banking_item_id }
# account_id is not uniquely scoped: uid already enforces one-account-per-identifier per item
# Helper to get account using account_providers system
def current_account

View File

@@ -6,7 +6,7 @@ class EnableBankingEntry::Processor
# enable_banking_transaction is the raw hash fetched from Enable Banking API
# Transaction structure from Enable Banking:
# {
# transaction_id, entry_reference, booking_date, value_date,
# transaction_id, entry_reference, booking_date, value_date, transaction_date,
# transaction_amount: { amount, currency },
# creditor_name, debtor_name, remittance_information, ...
# }
@@ -173,8 +173,8 @@ class EnableBankingEntry::Processor
end
def date
# Prefer booking_date, fall back to value_date
date_value = data[:booking_date] || data[:value_date]
# Prefer booking_date, fall back to value_date, then transaction_date
date_value = data[:booking_date] || data[:value_date] || data[:transaction_date]
case date_value
when String

View File

@@ -30,7 +30,7 @@ class EnableBankingItem::SyncCompleteEvent
family,
target: "enable_banking-providers-panel",
partial: "settings/providers/enable_banking_panel",
locals: { enable_banking_items: enable_banking_items }
locals: { enable_banking_items: enable_banking_items, family: family }
)
# Let family handle sync notifications

View File

@@ -19,6 +19,7 @@ class Family < ApplicationRecord
MONIKERS = [ "Family", "Group" ].freeze
ASSISTANT_TYPES = %w[builtin external].freeze
has_many :users, dependent: :destroy
has_many :accounts, dependent: :destroy
@@ -47,6 +48,7 @@ class Family < ApplicationRecord
validates :date_format, inclusion: { in: DATE_FORMATS.map(&:last) }
validates :month_start_day, inclusion: { in: 1..28 }
validates :moniker, inclusion: { in: MONIKERS }
validates :assistant_type, inclusion: { in: ASSISTANT_TYPES }
def moniker_label
@@ -153,7 +155,6 @@ class Family < ApplicationRecord
I18n.with_locale(locale) do
categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat|
cat.color = "#0d9488"
cat.classification = "expense"
cat.lucide_icon = "trending-up"
end
end

View File

@@ -69,8 +69,7 @@ class Family::AutoCategorizer
id: category.id,
name: category.name,
is_subcategory: category.subcategory?,
parent_id: category.parent_id,
classification: category.classification
parent_id: category.parent_id
}
end
end

View File

@@ -105,7 +105,7 @@ class Family::DataExporter
def generate_categories_csv
CSV.generate do |csv|
csv << [ "name", "color", "parent_category", "classification", "lucide_icon" ]
csv << [ "name", "color", "parent_category", "lucide_icon" ]
# Only export categories belonging to this family
@family.categories.includes(:parent).find_each do |category|
@@ -113,7 +113,6 @@ class Family::DataExporter
category.name,
category.color,
category.parent&.name,
category.classification,
category.lucide_icon
]
end

View File

@@ -1,8 +1,27 @@
module Family::Subscribeable
extend ActiveSupport::Concern
CLEANUP_GRACE_PERIOD = 14.days
ARCHIVE_TRANSACTION_THRESHOLD = 12
ARCHIVE_RECENT_ACTIVITY_WINDOW = 14.days
included do
has_one :subscription, dependent: :destroy
scope :inactive_trial_for_cleanup, -> {
cutoff_with_sub = CLEANUP_GRACE_PERIOD.ago
cutoff_without_sub = (Subscription::TRIAL_DAYS.days + CLEANUP_GRACE_PERIOD).ago
expired_trial = left_joins(:subscription)
.where(subscriptions: { status: [ "paused", "trialing" ] })
.where(subscriptions: { trial_ends_at: ...cutoff_with_sub })
no_subscription = left_joins(:subscription)
.where(subscriptions: { id: nil })
.where(families: { created_at: ...cutoff_without_sub })
where(id: expired_trial).or(where(id: no_subscription))
}
end
def payment_email
@@ -85,4 +104,13 @@ module Family::Subscribeable
subscription.update!(status: "paused")
end
end
def requires_data_archive?
return false unless transactions.count > ARCHIVE_TRANSACTION_THRESHOLD
trial_end = subscription&.trial_ends_at || (created_at + Subscription::TRIAL_DAYS.days)
recent_window_start = trial_end - ARCHIVE_RECENT_ACTIVITY_WINDOW
entries.where(date: recent_window_start..trial_end).exists?
end
end

View File

@@ -1,8 +1,9 @@
class Holding::ForwardCalculator
attr_reader :account
def initialize(account)
def initialize(account, security_ids: nil)
@account = account
@security_ids = security_ids
# Track cost basis per security: { security_id => { total_cost: BigDecimal, total_qty: BigDecimal } }
@cost_basis_tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } }
end
@@ -27,7 +28,7 @@ class Holding::ForwardCalculator
private
def portfolio_cache
@portfolio_cache ||= Holding::PortfolioCache.new(account)
@portfolio_cache ||= Holding::PortfolioCache.new(account, security_ids: @security_ids)
end
def empty_portfolio
@@ -55,6 +56,8 @@ class Holding::ForwardCalculator
def build_holdings(portfolio, date, price_source: nil)
portfolio.map do |security_id, qty|
next if @security_ids && !@security_ids.include?(security_id)
price = portfolio_cache.get_price(security_id, date, source: price_source)
if price.nil?

View File

@@ -1,9 +1,10 @@
# "Materializes" holdings (similar to a DB materialized view, but done at the app level)
# into a series of records we can easily query and join with other data.
class Holding::Materializer
def initialize(account, strategy:)
def initialize(account, strategy:, security_ids: nil)
@account = account
@strategy = strategy
@security_ids = security_ids
end
def materialize_holdings
@@ -12,7 +13,7 @@ class Holding::Materializer
Rails.logger.info("Persisting #{@holdings.size} holdings")
persist_holdings
if strategy == :forward
if strategy == :forward && security_ids.nil?
purge_stale_holdings
end
@@ -28,7 +29,7 @@ class Holding::Materializer
end
private
attr_reader :account, :strategy
attr_reader :account, :strategy, :security_ids
def calculate_holdings
@holdings = calculator.calculate
@@ -164,9 +165,9 @@ class Holding::Materializer
def calculator
if strategy == :reverse
portfolio_snapshot = Holding::PortfolioSnapshot.new(account)
Holding::ReverseCalculator.new(account, portfolio_snapshot: portfolio_snapshot)
Holding::ReverseCalculator.new(account, portfolio_snapshot: portfolio_snapshot, security_ids: security_ids)
else
Holding::ForwardCalculator.new(account)
Holding::ForwardCalculator.new(account, security_ids: security_ids)
end
end
end

View File

@@ -7,9 +7,10 @@ class Holding::PortfolioCache
end
end
def initialize(account, use_holdings: false)
def initialize(account, use_holdings: false, security_ids: nil)
@account = account
@use_holdings = use_holdings
@security_ids = security_ids
load_prices
end
@@ -62,10 +63,12 @@ class Holding::PortfolioCache
def collect_unique_securities
unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq
unique_securities_from_trades = unique_securities_from_trades.select { |s| @security_ids.include?(s.id) } if @security_ids
return unique_securities_from_trades unless use_holdings
unique_securities_from_holdings = holdings.map(&:security).uniq
unique_securities_from_holdings = unique_securities_from_holdings.select { |s| @security_ids.include?(s.id) } if @security_ids
(unique_securities_from_trades + unique_securities_from_holdings).uniq
end

View File

@@ -1,9 +1,10 @@
class Holding::ReverseCalculator
attr_reader :account, :portfolio_snapshot
def initialize(account, portfolio_snapshot:)
def initialize(account, portfolio_snapshot:, security_ids: nil)
@account = account
@portfolio_snapshot = portfolio_snapshot
@security_ids = security_ids
end
def calculate
@@ -19,7 +20,7 @@ class Holding::ReverseCalculator
# since it is common for a provider to supply "current day" holdings but not all the historical
# trades that make up those holdings.
def portfolio_cache
@portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true)
@portfolio_cache ||= Holding::PortfolioCache.new(account, use_holdings: true, security_ids: @security_ids)
end
def calculate_holdings
@@ -57,6 +58,8 @@ class Holding::ReverseCalculator
def build_holdings(portfolio, date, price_source: nil)
portfolio.map do |security_id, qty|
next if @security_ids && !@security_ids.include?(security_id)
price = portfolio_cache.get_price(security_id, date, source: price_source)
if price.nil?

View File

@@ -9,7 +9,7 @@ class Import < ApplicationRecord
DOCUMENT_TYPES = %w[bank_statement credit_card_statement investment_statement financial_document contract other].freeze
TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport].freeze
TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport RuleImport PdfImport QifImport].freeze
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze
@@ -197,6 +197,10 @@ class Import < ApplicationRecord
[]
end
def rows_ordered
rows.ordered
end
def uploaded?
raw_file_str.present?
end

View File

@@ -2,10 +2,26 @@ class Import::CategoryMapping < Import::Mapping
class << self
def mappables_by_key(import)
unique_values = import.rows.map(&:category).uniq
categories = import.family.categories.where(name: unique_values).index_by(&:name)
unique_values.index_with { |value| categories[value] }
# For hierarchical QIF keys like "Home:Home Improvement", look up the child
# name ("Home Improvement") since category names are unique per family.
lookup_names = unique_values.map { |v| leaf_category_name(v) }
categories = import.family.categories.where(name: lookup_names).index_by(&:name)
unique_values.index_with { |value| categories[leaf_category_name(value)] }
end
private
# Returns the leaf (child) name for a potentially hierarchical key.
# "Home:Home Improvement" → "Home Improvement"
# "Fees & Charges" → "Fees & Charges"
def leaf_category_name(key)
return "" if key.blank?
parts = key.to_s.split(":", 2)
parts.length == 2 ? parts[1].strip : key
end
end
def selectable_values
@@ -33,7 +49,30 @@ class Import::CategoryMapping < Import::Mapping
def create_mappable!
return unless creatable?
self.mappable = import.family.categories.find_or_create_by!(name: key)
parts = key.split(":", 2)
if parts.length == 2
parent_name = parts[0].strip
child_name = parts[1].strip
# Ensure the parent category exists before creating the child.
parent = import.family.categories.find_or_create_by!(name: parent_name) do |cat|
cat.color = Category::COLORS.sample
cat.lucide_icon = Category.suggested_icon(parent_name)
end
self.mappable = import.family.categories.find_or_create_by!(name: child_name) do |cat|
cat.parent = parent
cat.color = parent.color
cat.lucide_icon = Category.suggested_icon(child_name)
end
else
self.mappable = import.family.categories.find_or_create_by!(name: key) do |cat|
cat.color = Category::COLORS.sample
cat.lucide_icon = Category.suggested_icon(key)
end
end
save!
end
end

View File

@@ -36,6 +36,65 @@ class IncomeStatement
build_period_total(classification: "income", period: period)
end
def net_category_totals(period: Period.current_month)
expense = expense_totals(period: period)
income = income_totals(period: period)
# Use a stable key for each category: id for persisted, invariant token for synthetic
cat_key = ->(ct) {
if ct.category.uncategorized?
:uncategorized
elsif ct.category.other_investments?
:other_investments
else
ct.category.id
end
}
expense_by_cat = expense.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) }
income_by_cat = income.category_totals.reject { |ct| ct.category.subcategory? }.index_by { |ct| cat_key.call(ct) }
all_keys = (expense_by_cat.keys + income_by_cat.keys).uniq
raw_expense_categories = []
raw_income_categories = []
all_keys.each do |key|
exp_ct = expense_by_cat[key]
inc_ct = income_by_cat[key]
exp_total = exp_ct&.total || 0
inc_total = inc_ct&.total || 0
net = exp_total - inc_total
category = exp_ct&.category || inc_ct&.category
if net > 0
raw_expense_categories << { category: category, total: net }
elsif net < 0
raw_income_categories << { category: category, total: net.abs }
end
end
total_net_expense = raw_expense_categories.sum { |r| r[:total] }
total_net_income = raw_income_categories.sum { |r| r[:total] }
net_expense_categories = raw_expense_categories.map do |r|
weight = total_net_expense.zero? ? 0 : (r[:total].to_f / total_net_expense) * 100
CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight)
end
net_income_categories = raw_income_categories.map do |r|
weight = total_net_income.zero? ? 0 : (r[:total].to_f / total_net_income) * 100
CategoryTotal.new(category: r[:category], total: r[:total], currency: family.currency, weight: weight)
end
NetCategoryTotals.new(
net_expense_categories: net_expense_categories,
net_income_categories: net_income_categories,
total_net_expense: total_net_expense,
total_net_income: total_net_income,
currency: family.currency
)
end
def median_expense(interval: "month", category: nil)
if category.present?
category_stats(interval: interval).find { |stat| stat.classification == "expense" && stat.category_id == category.id }&.median || 0
@@ -60,6 +119,7 @@ class IncomeStatement
ScopeTotals = Data.define(:transactions_count, :income_money, :expense_money)
PeriodTotal = Data.define(:classification, :total, :currency, :category_totals)
CategoryTotal = Data.define(:category, :total, :currency, :weight)
NetCategoryTotals = Data.define(:net_expense_categories, :net_income_categories, :total_net_expense, :total_net_income, :currency)
def categories
@categories ||= family.categories.all.to_a

View File

@@ -12,6 +12,7 @@ class IndexaCapitalAccount < ApplicationRecord
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
validates :indexa_capital_account_id, uniqueness: { scope: :indexa_capital_item_id, allow_nil: true }
# Scopes
scope :with_linked, -> { joins(:account_provider) }

View File

@@ -13,9 +13,11 @@ class Invitation < ApplicationRecord
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :role, presence: true, inclusion: { in: %w[admin member guest] }
validates :token, presence: true, uniqueness: true
validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family"
validate :no_duplicate_pending_invitation_in_family
validate :inviter_is_admin
validate :no_other_pending_invitation, on: :create
before_validation :normalize_email
before_validation :generate_token, on: :create
before_create :set_expiration
@@ -57,6 +59,39 @@ class Invitation < ApplicationRecord
self.expires_at = 3.days.from_now
end
def normalize_email
self.email = email.to_s.strip.downcase if email.present?
end
def no_other_pending_invitation
return if email.blank?
existing = if self.class.encryption_ready?
self.class.pending.where(email: email).where.not(family_id: family_id).exists?
else
self.class.pending.where("LOWER(email) = ?", email.downcase).where.not(family_id: family_id).exists?
end
if existing
errors.add(:email, "already has a pending invitation from another family")
end
end
def no_duplicate_pending_invitation_in_family
return if email.blank?
scope = self.class.pending.where(family_id: family_id)
scope = scope.where.not(id: id) if persisted?
exists = if self.class.encryption_ready?
scope.where(email: email).exists?
else
scope.where("LOWER(email) = ?", email.to_s.strip.downcase).exists?
end
errors.add(:email, "has already been invited to this family") if exists
end
def inviter_is_admin
inviter.admin?
end

View File

@@ -15,6 +15,7 @@ class LunchflowAccount < ApplicationRecord
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
validates :account_id, uniqueness: { scope: :lunchflow_item_id, allow_nil: true }
# Helper to get account using account_providers system
def current_account

View File

@@ -15,6 +15,7 @@ class MercuryAccount < ApplicationRecord
has_one :linked_account, through: :account_provider, source: :account
validates :name, :currency, presence: true
validates :account_id, uniqueness: { scope: :mercury_item_id }
# Helper to get account using account_providers system
def current_account

View File

@@ -19,6 +19,7 @@ class PlaidAccount < ApplicationRecord
has_one :linked_account, through: :account_provider, source: :account
validates :name, :plaid_type, :currency, presence: true
validates :plaid_id, uniqueness: { scope: :plaid_item_id }
validate :has_balance
# Helper to get account using new system first, falling back to legacy

View File

@@ -97,7 +97,6 @@ class PlaidAccount::Transactions::CategoryMatcher
user_categories.map do |user_category|
{
id: user_category.id,
classification: user_category.classification,
name: normalize_user_category_name(user_category.name)
}
end

Some files were not shown because too many files have changed in this diff Show More