mirror of
https://github.com/we-promise/sure.git
synced 2026-06-08 20:29:05 +00:00
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:
15
.env.example
15
.env.example
@@ -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
27
.github/workflows/pipelock.yml
vendored
Normal 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
79
app/assets/images/claw-dark.svg
Normal file
79
app/assets/images/claw-dark.svg
Normal 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 |
79
app/assets/images/claw.svg
Normal file
79
app/assets/images/claw.svg
Normal 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 |
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
94
app/components/DS/select.html.erb
Normal file
94
app/components/DS/select.html.erb
Normal 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>
|
||||
83
app/components/DS/select.rb
Normal file
83
app/components/DS/select.rb
Normal 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
|
||||
@@ -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")
|
||||
|
||||
17
app/controllers/admin/invitations_controller.rb
Normal file
17
app/controllers/admin/invitations_controller.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
35
app/controllers/api/v1/users_controller.rb
Normal file
35
app/controllers/api/v1/users_controller.rb
Normal 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
|
||||
13
app/controllers/archived_exports_controller.rb
Normal file
13
app/controllers/archived_exports_controller.rb
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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? }
|
||||
|
||||
68
app/controllers/import/qif_category_selections_controller.rb
Normal file
68
app/controllers/import/qif_category_selections_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
class InvestmentsController < ApplicationController
|
||||
include AccountableResource
|
||||
|
||||
permitted_accountable_attributes :id, :subtype
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
150
app/controllers/mcp_controller.rb
Normal file
150
app/controllers/mcp_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
|
||||
82
app/controllers/pending_duplicate_merges_controller.rb
Normal file
82
app/controllers/pending_duplicate_merges_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
98
app/controllers/transaction_attachments_controller.rb
Normal file
98
app/controllers/transaction_attachments_controller.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
app/javascript/controllers/attachment_upload_controller.js
Normal file
63
app/javascript/controllers/attachment_upload_controller.js
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
18
app/javascript/controllers/form_dropdown_controller.js
Normal file
18
app/javascript/controllers/form_dropdown_controller.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
182
app/javascript/controllers/select_controller.js
Normal file
182
app/javascript/controllers/select_controller.js
Normal 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`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
80
app/jobs/demo_family_refresh_job.rb
Normal file
80
app/jobs/demo_family_refresh_job.rb
Normal 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
|
||||
64
app/jobs/inactive_family_cleaner_job.rb
Normal file
64
app/jobs/inactive_family_cleaner_job.rb
Normal 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
|
||||
16
app/mailers/demo_family_refresh_mailer.rb
Normal file
16
app/mailers/demo_family_refresh_mailer.rb
Normal 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
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
29
app/models/archived_export.rb
Normal file
29
app/models/archived_export.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
13
app/models/assistant/base.rb
Normal file
13
app/models/assistant/base.rb
Normal 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
|
||||
95
app/models/assistant/builtin.rb
Normal file
95
app/models/assistant/builtin.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
110
app/models/assistant/external.rb
Normal file
110
app/models/assistant/external.rb
Normal 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
175
app/models/assistant/external/client.rb
vendored
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
428
app/models/concerns/qif_parser.rb
Normal file
428
app/models/concerns/qif_parser.rb
Normal 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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
class DeveloperMessage < Message
|
||||
def role
|
||||
"developer"
|
||||
end
|
||||
|
||||
private
|
||||
def broadcast?
|
||||
chat.debug_mode?
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user