mirror of
https://github.com/we-promise/sure.git
synced 2026-06-08 20:29:05 +00:00
Merge branch 'main' into add-config-import-csv-skip-first-x-rows
Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -70,8 +70,16 @@ POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_USER=postgres
|
||||
|
||||
# Redis configuration
|
||||
# Standard Redis URL (for direct connection)
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
|
||||
# Redis Sentinel configuration (for high availability)
|
||||
# When REDIS_SENTINEL_HOSTS is set, it takes precedence over REDIS_URL
|
||||
# REDIS_SENTINEL_HOSTS=sentinel1:26379,sentinel2:26379,sentinel3:26379
|
||||
# REDIS_SENTINEL_MASTER=mymaster
|
||||
# REDIS_SENTINEL_USERNAME=default
|
||||
# REDIS_PASSWORD=your-redis-password
|
||||
|
||||
# App Domain
|
||||
# This is the domain that your Sure instance will be hosted at. It is used to generate links in emails and other places.
|
||||
APP_DOMAIN=
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -344,7 +344,7 @@ jobs:
|
||||
uses: actions/checkout@v4.2.0
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ secrets.GH_PAT }}
|
||||
|
||||
- name: Bump alpha version
|
||||
run: |
|
||||
@@ -386,6 +386,7 @@ jobs:
|
||||
|
||||
# Verify the change
|
||||
echo "Updated version.rb:"
|
||||
grep "semver" "$VERSION_FILE"
|
||||
|
||||
- name: Commit and push version bump
|
||||
run: |
|
||||
@@ -398,6 +399,7 @@ jobs:
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit - version may have already been bumped"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "Bump version to next alpha after ${{ github.ref_name }} release"
|
||||
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -100,10 +100,14 @@ Two primary data ingestion methods:
|
||||
- SimpleFIN: pending via `pending: true` or `posted` blank/0 + `transacted_at`.
|
||||
- Plaid: pending via Plaid `pending: true` (stored at `extra["plaid"]["pending"]` for bank/credit transactions imported via `PlaidEntry::Processor`).
|
||||
- Storage: provider data on `Transaction#extra` (e.g., `extra["simplefin"]["pending"]`; FX uses `fx_from`, `fx_date`).
|
||||
- UI: “Pending” badge when `transaction.pending?` is true; no badge if provider omits pendings.
|
||||
- Configuration (default-off)
|
||||
- Centralized in `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`.
|
||||
- ENV-backed keys: `SIMPLEFIN_INCLUDE_PENDING=1`, `SIMPLEFIN_DEBUG_RAW=1`.
|
||||
- UI: "Pending" badge when `transaction.pending?` is true; no badge if provider omits pendings.
|
||||
- Configuration (default-on for pending)
|
||||
- SimpleFIN: `config/initializers/simplefin.rb` via `Rails.configuration.x.simplefin.*`.
|
||||
- Plaid: `config/initializers/plaid_config.rb` via `Rails.configuration.x.plaid.*`.
|
||||
- Pending transactions are fetched by default and handled via reconciliation/filtering.
|
||||
- Set `SIMPLEFIN_INCLUDE_PENDING=0` to disable pending fetching for SimpleFIN.
|
||||
- Set `PLAID_INCLUDE_PENDING=0` to disable pending fetching for Plaid.
|
||||
- Set `SIMPLEFIN_DEBUG_RAW=1` to enable raw payload debug logging.
|
||||
|
||||
Provider support notes:
|
||||
- SimpleFIN: supports pending + FX metadata (stored under `extra["simplefin"]`).
|
||||
|
||||
9
Gemfile
9
Gemfile
@@ -59,6 +59,7 @@ gem "countries"
|
||||
# OAuth & API Security
|
||||
gem "doorkeeper"
|
||||
gem "rack-attack", "~> 6.6"
|
||||
gem "pundit"
|
||||
gem "faraday"
|
||||
gem "faraday-retry"
|
||||
gem "faraday-multipart"
|
||||
@@ -68,6 +69,7 @@ gem "pagy"
|
||||
gem "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[windows jruby]
|
||||
gem "csv"
|
||||
gem "rchardet" # Character encoding detection
|
||||
gem "redcarpet"
|
||||
gem "stripe"
|
||||
gem "plaid"
|
||||
@@ -77,17 +79,22 @@ gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
gem "rubyzip", "~> 2.3"
|
||||
|
||||
# OpenID Connect & OAuth authentication
|
||||
# OpenID Connect, OAuth & SAML authentication
|
||||
gem "omniauth", "~> 2.1"
|
||||
gem "omniauth-rails_csrf_protection"
|
||||
gem "omniauth_openid_connect"
|
||||
gem "omniauth-google-oauth2"
|
||||
gem "omniauth-github"
|
||||
gem "omniauth-saml", "~> 2.1"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
gem "after_commit_everywhere", "~> 1.0"
|
||||
|
||||
# Feature flags
|
||||
gem "flipper"
|
||||
gem "flipper-active_record"
|
||||
|
||||
# AI
|
||||
gem "ruby-openai"
|
||||
gem "langfuse-ruby", "~> 0.1.4", require: "langfuse"
|
||||
|
||||
19
Gemfile.lock
19
Gemfile.lock
@@ -216,6 +216,11 @@ GEM
|
||||
ffi (1.17.2-x86_64-darwin)
|
||||
ffi (1.17.2-x86_64-linux-gnu)
|
||||
ffi (1.17.2-x86_64-linux-musl)
|
||||
flipper (1.3.6)
|
||||
concurrent-ruby (< 2)
|
||||
flipper-active_record (1.3.6)
|
||||
activerecord (>= 4.2, < 9)
|
||||
flipper (~> 1.3.6)
|
||||
foreman (0.88.1)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
@@ -415,6 +420,9 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-saml (2.2.4)
|
||||
omniauth (~> 2.1)
|
||||
ruby-saml (~> 1.18)
|
||||
omniauth_openid_connect (0.8.0)
|
||||
omniauth (>= 1.9, < 3)
|
||||
openid_connect (~> 2.2)
|
||||
@@ -461,6 +469,8 @@ GEM
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.2)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.18)
|
||||
@@ -528,6 +538,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
rbs (3.9.4)
|
||||
logger
|
||||
rchardet (1.10.0)
|
||||
rdoc (6.14.2)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
@@ -614,6 +625,9 @@ GEM
|
||||
faraday (>= 1)
|
||||
faraday-multipart (>= 1)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
rexml
|
||||
ruby-statistics (4.1.0)
|
||||
ruby-vips (2.2.4)
|
||||
ffi (~> 1.12)
|
||||
@@ -769,6 +783,8 @@ DEPENDENCIES
|
||||
faraday
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
flipper
|
||||
flipper-active_record
|
||||
foreman
|
||||
hotwire-livereload
|
||||
hotwire_combobox
|
||||
@@ -790,6 +806,7 @@ DEPENDENCIES
|
||||
omniauth-github
|
||||
omniauth-google-oauth2
|
||||
omniauth-rails_csrf_protection
|
||||
omniauth-saml (~> 2.1)
|
||||
omniauth_openid_connect
|
||||
ostruct
|
||||
pagy
|
||||
@@ -798,10 +815,12 @@ DEPENDENCIES
|
||||
posthog-ruby
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
pundit
|
||||
rack-attack (~> 6.6)
|
||||
rack-mini-profiler
|
||||
rails (~> 7.2.2)
|
||||
rails-settings-cached
|
||||
rchardet
|
||||
redcarpet
|
||||
redis (~> 5.4)
|
||||
rotp (~> 6.3)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
[](https://deepwiki.com/we-promise/sure)
|
||||
[](https://app.dosu.dev/a72bdcfd-15f5-4edc-bd85-ea0daa6c3adc/ask)
|
||||
|
||||
<img width="1270" height="1140" alt="sure_shot" src="https://github.com/user-attachments/assets/9c6e03cc-3490-40ab-9a68-52e042c51293" />
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
@import "./google-sign-in.css";
|
||||
@import "./date-picker-dark-mode.css";
|
||||
@import "./print-report.css";
|
||||
|
||||
@layer components {
|
||||
.pcr-app{
|
||||
|
||||
296
app/assets/tailwind/print-report.css
Normal file
296
app/assets/tailwind/print-report.css
Normal file
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
Print Report Styles
|
||||
Tufte-inspired styling for the printable financial report.
|
||||
Uses design system tokens where applicable.
|
||||
*/
|
||||
|
||||
/* Print Body & Container */
|
||||
.print-body {
|
||||
background: var(--color-white);
|
||||
color: var(--color-gray-900);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.print-container {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
|
||||
.tufte-report {
|
||||
font-size: 11px;
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.tufte-header {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 2px solid var(--color-gray-900);
|
||||
}
|
||||
|
||||
.tufte-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 4px 0;
|
||||
color: var(--color-gray-900);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.tufte-period {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--color-gray-600);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.tufte-meta {
|
||||
font-size: 10px;
|
||||
color: var(--color-gray-500);
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.tufte-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tufte-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--color-gray-900);
|
||||
margin: 0 0 12px 0;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.tufte-subsection {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin: 16px 0 8px 0;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--color-gray-100);
|
||||
}
|
||||
|
||||
/* Metric Cards */
|
||||
.tufte-metric-card {
|
||||
display: inline-block;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tufte-metric-card-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-gray-500);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-change {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-sm .tufte-metric-card-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tufte-metric-card-sm .tufte-metric-card-label {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* Metric Row (horizontal layout) */
|
||||
.tufte-metric-row {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Semantic Colors */
|
||||
.tufte-income { color: var(--color-green-700); }
|
||||
.tufte-expense { color: var(--color-red-700); }
|
||||
.tufte-muted { color: var(--color-gray-500); font-size: 10px; }
|
||||
.tufte-up { color: var(--color-green-700); }
|
||||
.tufte-down { color: var(--color-red-700); }
|
||||
|
||||
/* Two Column Layout */
|
||||
.tufte-two-col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 32px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Tables - Clean, readable style */
|
||||
.tufte-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tufte-table thead th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-gray-600);
|
||||
padding: 8px 12px 8px 0;
|
||||
border-bottom: 2px solid var(--color-gray-900);
|
||||
}
|
||||
|
||||
.tufte-table tbody td {
|
||||
padding: 6px 12px 6px 0;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tufte-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tufte-table tfoot td {
|
||||
padding: 8px 12px 6px 0;
|
||||
border-top: 2px solid var(--color-gray-900);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tufte-table.tufte-compact thead th {
|
||||
padding: 6px 8px 6px 0;
|
||||
}
|
||||
|
||||
.tufte-table.tufte-compact tbody td {
|
||||
padding: 5px 8px 5px 0;
|
||||
}
|
||||
|
||||
.tufte-right {
|
||||
text-align: right;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.tufte-highlight {
|
||||
background: var(--color-yellow-100);
|
||||
}
|
||||
|
||||
.tufte-highlight td:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Category Dots */
|
||||
.tufte-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Footnotes */
|
||||
.tufte-footnote {
|
||||
font-size: 10px;
|
||||
color: var(--color-gray-500);
|
||||
margin-top: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.tufte-footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-gray-200);
|
||||
font-size: 10px;
|
||||
color: var(--color-gray-500);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Print-specific overrides */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 15mm 18mm;
|
||||
}
|
||||
|
||||
/* Scoped to .print-body to avoid affecting other pages when printing */
|
||||
.print-body {
|
||||
font-size: 10px;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.print-container {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tufte-section {
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.tufte-section-title {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.tufte-table {
|
||||
page-break-inside: auto;
|
||||
}
|
||||
|
||||
.tufte-table thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.tufte-table tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.tufte-two-col {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.tufte-keep-together {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.tufte-header {
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
/* Force colors in print */
|
||||
.tufte-income { color: var(--color-green-700) !important; }
|
||||
.tufte-expense { color: var(--color-red-700) !important; }
|
||||
.tufte-up { color: var(--color-green-700) !important; }
|
||||
.tufte-down { color: var(--color-red-700) !important; }
|
||||
|
||||
.tufte-footer {
|
||||
page-break-before: avoid;
|
||||
}
|
||||
|
||||
.tufte-highlight {
|
||||
background: var(--color-yellow-100) !important;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,42 @@
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render DS::Menu.new(variant: "button", no_padding: true) do |menu| %>
|
||||
<% menu.with_button(
|
||||
id: "activity-status-filter-button",
|
||||
type: "button",
|
||||
text: t("accounts.show.activity.filter"),
|
||||
variant: "outline",
|
||||
icon: "list-filter"
|
||||
) %>
|
||||
|
||||
<% menu.with_custom_content do %>
|
||||
<div class="p-3 space-y-3 min-w-[160px]">
|
||||
<p class="text-xs font-medium text-secondary uppercase"><%= t("accounts.show.activity.status") %></p>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= check_box_tag "q[status][]",
|
||||
"confirmed",
|
||||
params.dig(:q, :status)&.include?("confirmed"),
|
||||
id: "q_status_confirmed",
|
||||
class: "checkbox checkbox--light",
|
||||
form: "entries-search",
|
||||
onchange: "document.getElementById('entries-search').requestSubmit()" %>
|
||||
<%= label_tag "q_status_confirmed", t("accounts.show.activity.confirmed"), class: "text-sm text-primary" %>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= check_box_tag "q[status][]",
|
||||
"pending",
|
||||
params.dig(:q, :status)&.include?("pending"),
|
||||
id: "q_status_pending",
|
||||
class: "checkbox checkbox--light",
|
||||
form: "entries-search",
|
||||
onchange: "document.getElementById('entries-search').requestSubmit()" %>
|
||||
<%= label_tag "q_status_pending", t("accounts.show.activity.pending"), class: "text-sm text-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= button_tag type: "button",
|
||||
id: "toggle-checkboxes-button",
|
||||
|
||||
@@ -69,6 +69,102 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Pending→posted reconciliation %>
|
||||
<% if has_pending_reconciled? %>
|
||||
<div class="mt-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= helpers.icon "check-circle", size: "sm", color: "success" %>
|
||||
<span class="text-success"><%= t("provider_sync_summary.health.pending_reconciled", count: pending_reconciled) %></span>
|
||||
</div>
|
||||
<% if pending_reconciled_details.any? %>
|
||||
<details class="mt-1">
|
||||
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
|
||||
<%= t("provider_sync_summary.health.view_reconciled") %>
|
||||
</summary>
|
||||
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
|
||||
<% pending_reconciled_details.each do |detail| %>
|
||||
<p class="text-xs text-success">
|
||||
<%= detail["account_name"] %>: <%= detail["pending_name"] %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Duplicate suggestions needing review %>
|
||||
<% if has_duplicate_suggestions_created? %>
|
||||
<div class="mt-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= helpers.icon "alert-triangle", size: "sm", color: "warning" %>
|
||||
<span class="text-warning"><%= t("provider_sync_summary.health.duplicate_suggestions", count: duplicate_suggestions_created) %></span>
|
||||
</div>
|
||||
<% if duplicate_suggestions_details.any? %>
|
||||
<details class="mt-1">
|
||||
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
|
||||
<%= t("provider_sync_summary.health.view_duplicate_suggestions") %>
|
||||
</summary>
|
||||
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
|
||||
<% duplicate_suggestions_details.each do |detail| %>
|
||||
<p class="text-xs text-warning">
|
||||
<%= detail["account_name"] %>: <%= detail["pending_name"] %> → <%= detail["posted_name"] %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Stale pending transactions (auto-excluded) %>
|
||||
<% if has_stale_pending? %>
|
||||
<div class="mt-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= helpers.icon "clock", size: "sm", color: "warning" %>
|
||||
<span class="text-warning"><%= t("provider_sync_summary.health.stale_pending", count: stale_pending_excluded) %></span>
|
||||
</div>
|
||||
<% if stale_pending_details.any? %>
|
||||
<details class="mt-1">
|
||||
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
|
||||
<%= t("provider_sync_summary.health.view_stale_pending") %>
|
||||
</summary>
|
||||
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
|
||||
<% stale_pending_details.each do |detail| %>
|
||||
<p class="text-xs text-warning">
|
||||
<%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_pending_count", count: detail["count"]) %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Stale unmatched pending (need manual review) %>
|
||||
<% if has_stale_unmatched_pending? %>
|
||||
<div class="mt-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= helpers.icon "help-circle", size: "sm" %>
|
||||
<span class="text-secondary"><%= t("provider_sync_summary.health.stale_unmatched", count: stale_unmatched_pending) %></span>
|
||||
</div>
|
||||
<% if stale_unmatched_details.any? %>
|
||||
<details class="mt-1">
|
||||
<summary class="text-xs cursor-pointer text-secondary hover:text-primary">
|
||||
<%= t("provider_sync_summary.health.view_stale_unmatched") %>
|
||||
</summary>
|
||||
<div class="mt-1 pl-2 border-l-2 border-surface-inset space-y-1">
|
||||
<% stale_unmatched_details.each do |detail| %>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= detail["account_name"] %>: <%= t("provider_sync_summary.health.stale_unmatched_count", count: detail["count"]) %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Data quality warnings %>
|
||||
<% if has_data_quality_issues? %>
|
||||
<div class="flex items-center gap-3 mt-1">
|
||||
|
||||
@@ -127,6 +127,58 @@ class ProviderSyncSummary < ViewComponent::Base
|
||||
total_errors > 0
|
||||
end
|
||||
|
||||
# Stale pending transactions (auto-excluded)
|
||||
def stale_pending_excluded
|
||||
stats["stale_pending_excluded"].to_i
|
||||
end
|
||||
|
||||
def has_stale_pending?
|
||||
stale_pending_excluded > 0
|
||||
end
|
||||
|
||||
def stale_pending_details
|
||||
stats["stale_pending_details"] || []
|
||||
end
|
||||
|
||||
# Stale unmatched pending (need manual review - couldn't be automatically matched)
|
||||
def stale_unmatched_pending
|
||||
stats["stale_unmatched_pending"].to_i
|
||||
end
|
||||
|
||||
def has_stale_unmatched_pending?
|
||||
stale_unmatched_pending > 0
|
||||
end
|
||||
|
||||
def stale_unmatched_details
|
||||
stats["stale_unmatched_details"] || []
|
||||
end
|
||||
|
||||
# Pending→posted reconciliation stats
|
||||
def pending_reconciled
|
||||
stats["pending_reconciled"].to_i
|
||||
end
|
||||
|
||||
def has_pending_reconciled?
|
||||
pending_reconciled > 0
|
||||
end
|
||||
|
||||
def pending_reconciled_details
|
||||
stats["pending_reconciled_details"] || []
|
||||
end
|
||||
|
||||
# Duplicate suggestions needing user review
|
||||
def duplicate_suggestions_created
|
||||
stats["duplicate_suggestions_created"].to_i
|
||||
end
|
||||
|
||||
def has_duplicate_suggestions_created?
|
||||
duplicate_suggestions_created > 0
|
||||
end
|
||||
|
||||
def duplicate_suggestions_details
|
||||
stats["duplicate_suggestions_details"] || []
|
||||
end
|
||||
|
||||
# Data quality / warnings
|
||||
def data_warnings
|
||||
stats["data_warnings"].to_i
|
||||
|
||||
@@ -35,7 +35,7 @@ class AccountsController < ApplicationController
|
||||
def show
|
||||
@chart_view = params[:chart_view] || "balance"
|
||||
@tab = params[:tab]
|
||||
@q = params.fetch(:q, {}).permit(:search)
|
||||
@q = params.fetch(:q, {}).permit(:search, status: [])
|
||||
entries = @account.entries.search(@q).reverse_chronological
|
||||
|
||||
@pagy, @entries = pagy(entries, limit: params[:per_page] || "10")
|
||||
|
||||
16
app/controllers/admin/base_controller.rb
Normal file
16
app/controllers/admin/base_controller.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class BaseController < ApplicationController
|
||||
before_action :require_super_admin!
|
||||
|
||||
layout "settings"
|
||||
|
||||
private
|
||||
def require_super_admin!
|
||||
unless Current.user&.super_admin?
|
||||
redirect_to root_path, alert: t("admin.unauthorized")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
163
app/controllers/admin/sso_providers_controller.rb
Normal file
163
app/controllers/admin/sso_providers_controller.rb
Normal file
@@ -0,0 +1,163 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class SsoProvidersController < Admin::BaseController
|
||||
before_action :set_sso_provider, only: %i[show edit update destroy toggle test_connection]
|
||||
|
||||
def index
|
||||
authorize SsoProvider
|
||||
@sso_providers = policy_scope(SsoProvider).order(:name)
|
||||
|
||||
# Load runtime providers (from YAML/env) that might not be in the database
|
||||
# This helps show users that legacy providers are active but not manageable via UI
|
||||
@runtime_providers = Rails.configuration.x.auth.sso_providers || []
|
||||
db_provider_names = @sso_providers.pluck(:name)
|
||||
@legacy_providers = @runtime_providers.reject { |p| db_provider_names.include?(p[:name].to_s) }
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @sso_provider
|
||||
end
|
||||
|
||||
def new
|
||||
@sso_provider = SsoProvider.new
|
||||
authorize @sso_provider
|
||||
end
|
||||
|
||||
def create
|
||||
@sso_provider = SsoProvider.new(processed_params)
|
||||
authorize @sso_provider
|
||||
|
||||
# Auto-generate redirect_uri if not provided
|
||||
if @sso_provider.redirect_uri.blank? && @sso_provider.name.present?
|
||||
@sso_provider.redirect_uri = "#{request.base_url}/auth/#{@sso_provider.name}/callback"
|
||||
end
|
||||
|
||||
if @sso_provider.save
|
||||
log_provider_change(:create, @sso_provider)
|
||||
clear_provider_cache
|
||||
redirect_to admin_sso_providers_path, notice: t(".success")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
authorize @sso_provider
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @sso_provider
|
||||
|
||||
# Auto-update redirect_uri if name changed
|
||||
params_hash = processed_params.to_h
|
||||
if params_hash[:name].present? && params_hash[:name] != @sso_provider.name
|
||||
params_hash[:redirect_uri] = "#{request.base_url}/auth/#{params_hash[:name]}/callback"
|
||||
end
|
||||
|
||||
if @sso_provider.update(params_hash)
|
||||
log_provider_change(:update, @sso_provider)
|
||||
clear_provider_cache
|
||||
redirect_to admin_sso_providers_path, notice: t(".success")
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize @sso_provider
|
||||
|
||||
@sso_provider.destroy!
|
||||
log_provider_change(:destroy, @sso_provider)
|
||||
clear_provider_cache
|
||||
|
||||
redirect_to admin_sso_providers_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def toggle
|
||||
authorize @sso_provider
|
||||
|
||||
@sso_provider.update!(enabled: !@sso_provider.enabled)
|
||||
log_provider_change(:toggle, @sso_provider)
|
||||
clear_provider_cache
|
||||
|
||||
notice = @sso_provider.enabled? ? t(".success_enabled") : t(".success_disabled")
|
||||
redirect_to admin_sso_providers_path, notice: notice
|
||||
end
|
||||
|
||||
def test_connection
|
||||
authorize @sso_provider
|
||||
|
||||
tester = SsoProviderTester.new(@sso_provider)
|
||||
result = tester.test!
|
||||
|
||||
render json: {
|
||||
success: result.success?,
|
||||
message: result.message,
|
||||
details: result.details
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def set_sso_provider
|
||||
@sso_provider = SsoProvider.find(params[:id])
|
||||
end
|
||||
|
||||
def sso_provider_params
|
||||
params.require(:sso_provider).permit(
|
||||
:strategy,
|
||||
:name,
|
||||
:label,
|
||||
:icon,
|
||||
:enabled,
|
||||
:issuer,
|
||||
:client_id,
|
||||
:client_secret,
|
||||
:redirect_uri,
|
||||
:scopes,
|
||||
:prompt,
|
||||
settings: [
|
||||
:default_role, :scopes, :prompt,
|
||||
# SAML settings
|
||||
:idp_metadata_url, :idp_sso_url, :idp_slo_url,
|
||||
:idp_certificate, :idp_cert_fingerprint, :name_id_format,
|
||||
role_mapping: {}
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
# Process params to convert role_mapping comma-separated strings to arrays
|
||||
def processed_params
|
||||
result = sso_provider_params.to_h
|
||||
|
||||
if result[:settings].present? && result[:settings][:role_mapping].present?
|
||||
result[:settings][:role_mapping] = result[:settings][:role_mapping].transform_values do |v|
|
||||
# Convert comma-separated string to array, removing empty values
|
||||
v.to_s.split(",").map(&:strip).reject(&:blank?)
|
||||
end
|
||||
|
||||
# Remove empty role mappings
|
||||
result[:settings][:role_mapping] = result[:settings][:role_mapping].reject { |_, v| v.empty? }
|
||||
result[:settings].delete(:role_mapping) if result[:settings][:role_mapping].empty?
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def log_provider_change(action, provider)
|
||||
Rails.logger.info(
|
||||
"[Admin::SsoProviders] #{action.to_s.upcase} - " \
|
||||
"user_id=#{Current.user.id} " \
|
||||
"provider_id=#{provider.id} " \
|
||||
"provider_name=#{provider.name} " \
|
||||
"strategy=#{provider.strategy} " \
|
||||
"enabled=#{provider.enabled}"
|
||||
)
|
||||
end
|
||||
|
||||
def clear_provider_cache
|
||||
ProviderLoader.clear_cache
|
||||
Rails.logger.info("[Admin::SsoProviders] Provider cache cleared by user_id=#{Current.user.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
38
app/controllers/admin/users_controller.rb
Normal file
38
app/controllers/admin/users_controller.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class UsersController < Admin::BaseController
|
||||
before_action :set_user, only: %i[update]
|
||||
|
||||
def index
|
||||
authorize User
|
||||
@users = policy_scope(User).order(:email)
|
||||
end
|
||||
|
||||
def update
|
||||
authorize @user
|
||||
|
||||
if @user.update(user_params)
|
||||
Rails.logger.info(
|
||||
"[Admin::Users] Role changed - " \
|
||||
"by_user_id=#{Current.user.id} " \
|
||||
"target_user_id=#{@user.id} " \
|
||||
"new_role=#{@user.role}"
|
||||
)
|
||||
redirect_to admin_users_path, notice: t(".success")
|
||||
else
|
||||
redirect_to admin_users_path, alert: t(".failure")
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = User.find(params[:id])
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:role)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -28,7 +28,12 @@ class Api::V1::ChatsController < Api::V1::BaseController
|
||||
)
|
||||
|
||||
if @message.save
|
||||
AssistantResponseJob.perform_later(@message)
|
||||
# NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses
|
||||
# UserMessage model already triggers AssistantResponseJob via after_create_commit callback
|
||||
# in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice,
|
||||
# resulting in duplicate AI responses with different content and wasted tokens.
|
||||
# See: https://github.com/dwvwdv/sure (mobile app integration issue)
|
||||
# AssistantResponseJob.perform_later(@message)
|
||||
render :show, status: :created
|
||||
else
|
||||
@chat.destroy
|
||||
|
||||
85
app/controllers/api/v1/merchants_controller.rb
Normal file
85
app/controllers/api/v1/merchants_controller.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
# API v1 endpoint for merchants
|
||||
# Provides read-only access to family and provider merchants
|
||||
#
|
||||
# @example List all merchants
|
||||
# GET /api/v1/merchants
|
||||
#
|
||||
# @example Get a specific merchant
|
||||
# GET /api/v1/merchants/:id
|
||||
#
|
||||
class MerchantsController < BaseController
|
||||
before_action :ensure_read_scope
|
||||
|
||||
# List all merchants available to the family
|
||||
#
|
||||
# Returns both family-owned merchants and provider merchants
|
||||
# that are assigned to the family's transactions.
|
||||
#
|
||||
# @return [Array<Hash>] JSON array of merchant objects
|
||||
def index
|
||||
family = current_resource_owner.family
|
||||
|
||||
# Single query with OR conditions - more efficient than Ruby deduplication
|
||||
family_merchant_ids = family.merchants.select(:id)
|
||||
provider_merchant_ids = family.transactions.select(:merchant_id)
|
||||
|
||||
@merchants = Merchant
|
||||
.where(id: family_merchant_ids)
|
||||
.or(Merchant.where(id: provider_merchant_ids, type: "ProviderMerchant"))
|
||||
.distinct
|
||||
.alphabetically
|
||||
|
||||
render json: @merchants.map { |m| merchant_json(m) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("API Merchants Error: #{e.message}")
|
||||
render json: { error: "Failed to fetch merchants" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# Get a specific merchant by ID
|
||||
#
|
||||
# Returns a merchant if it belongs to the family or is assigned
|
||||
# to any of the family's transactions.
|
||||
#
|
||||
# @param id [String] The merchant ID
|
||||
# @return [Hash] JSON merchant object or error
|
||||
def show
|
||||
family = current_resource_owner.family
|
||||
|
||||
@merchant = family.merchants.find_by(id: params[:id]) ||
|
||||
Merchant.joins(:transactions)
|
||||
.where(transactions: { account_id: family.accounts.select(:id) })
|
||||
.distinct
|
||||
.find_by(id: params[:id])
|
||||
|
||||
if @merchant
|
||||
render json: merchant_json(@merchant)
|
||||
else
|
||||
render json: { error: "Merchant not found" }, status: :not_found
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("API Merchant Show Error: #{e.message}")
|
||||
render json: { error: "Failed to fetch merchant" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Serialize a merchant to JSON format
|
||||
#
|
||||
# @param merchant [Merchant] The merchant to serialize
|
||||
# @return [Hash] JSON-serializable hash
|
||||
def merchant_json(merchant)
|
||||
{
|
||||
id: merchant.id,
|
||||
name: merchant.name,
|
||||
type: merchant.type,
|
||||
created_at: merchant.created_at,
|
||||
updated_at: merchant.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,7 +13,12 @@ class Api::V1::MessagesController < Api::V1::BaseController
|
||||
)
|
||||
|
||||
if @message.save
|
||||
AssistantResponseJob.perform_later(@message)
|
||||
# NOTE: Commenting out duplicate job enqueue to fix mobile app receiving duplicate AI responses
|
||||
# UserMessage model already triggers AssistantResponseJob via after_create_commit callback
|
||||
# in app/models/user_message.rb:10-12, so this manual enqueue causes the job to run twice,
|
||||
# resulting in duplicate AI responses with different content and wasted tokens.
|
||||
# See: https://github.com/dwvwdv/sure (mobile app integration issue)
|
||||
# AssistantResponseJob.perform_later(@message)
|
||||
render :show, status: :created
|
||||
else
|
||||
render json: { error: "Failed to create message", details: @message.errors.full_messages }, status: :unprocessable_entity
|
||||
|
||||
130
app/controllers/api/v1/tags_controller.rb
Normal file
130
app/controllers/api/v1/tags_controller.rb
Normal file
@@ -0,0 +1,130 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
# API v1 endpoint for tags
|
||||
# Provides full CRUD operations for family tags
|
||||
#
|
||||
# @example List all tags
|
||||
# GET /api/v1/tags
|
||||
#
|
||||
# @example Create a new tag
|
||||
# POST /api/v1/tags
|
||||
# { "tag": { "name": "WhiteHouse", "color": "#3b82f6" } }
|
||||
#
|
||||
class TagsController < BaseController
|
||||
before_action :ensure_read_scope, only: %i[index show]
|
||||
before_action :ensure_write_scope, only: %i[create update destroy]
|
||||
before_action :set_tag, only: %i[show update destroy]
|
||||
|
||||
# List all tags belonging to the family
|
||||
#
|
||||
# @return [Array<Hash>] JSON array of tag objects sorted alphabetically
|
||||
def index
|
||||
family = current_resource_owner.family
|
||||
@tags = family.tags.alphabetically
|
||||
|
||||
render json: @tags.map { |t| tag_json(t) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("API Tags Error: #{e.message}")
|
||||
render json: { error: "Failed to fetch tags" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# Get a specific tag by ID
|
||||
#
|
||||
# @param id [String] The tag ID
|
||||
# @return [Hash] JSON tag object
|
||||
def show
|
||||
render json: tag_json(@tag)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("API Tag Show Error: #{e.message}")
|
||||
render json: { error: "Failed to fetch tag" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# Create a new tag for the family
|
||||
#
|
||||
# @param name [String] Tag name (required)
|
||||
# @param color [String] Hex color code (optional, auto-assigned if not provided)
|
||||
# @return [Hash] JSON tag object with status 201
|
||||
def create
|
||||
family = current_resource_owner.family
|
||||
@tag = family.tags.new(tag_params)
|
||||
|
||||
# Assign random color if not provided
|
||||
@tag.color ||= Tag::COLORS.sample
|
||||
|
||||
if @tag.save
|
||||
render json: tag_json(@tag), status: :created
|
||||
else
|
||||
render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("API Tag Create Error: #{e.message}")
|
||||
render json: { error: "Failed to create tag" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# Update an existing tag
|
||||
#
|
||||
# @param id [String] The tag ID
|
||||
# @param name [String] New tag name (optional)
|
||||
# @param color [String] New hex color code (optional)
|
||||
# @return [Hash] JSON tag object
|
||||
def update
|
||||
if @tag.update(tag_params)
|
||||
render json: tag_json(@tag)
|
||||
else
|
||||
render json: { error: @tag.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("API Tag Update Error: #{e.message}")
|
||||
render json: { error: "Failed to update tag" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# Delete a tag
|
||||
#
|
||||
# @param id [String] The tag ID
|
||||
# @return [nil] Empty response with status 204
|
||||
def destroy
|
||||
@tag.destroy!
|
||||
head :no_content
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("API Tag Destroy Error: #{e.message}")
|
||||
render json: { error: "Failed to delete tag" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Find and set the tag from params
|
||||
#
|
||||
# @raise [ActiveRecord::RecordNotFound] if tag not found
|
||||
# @return [Tag] The found tag
|
||||
def set_tag
|
||||
family = current_resource_owner.family
|
||||
@tag = family.tags.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "Tag not found" }, status: :not_found
|
||||
end
|
||||
|
||||
# Strong parameters for tag creation/update
|
||||
#
|
||||
# @return [ActionController::Parameters] Permitted parameters
|
||||
def tag_params
|
||||
params.require(:tag).permit(:name, :color)
|
||||
end
|
||||
|
||||
# Serialize a tag to JSON format
|
||||
#
|
||||
# @param tag [Tag] The tag to serialize
|
||||
# @return [Hash] JSON-serializable hash
|
||||
def tag_json(tag)
|
||||
{
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
created_at: tag.created_at,
|
||||
updated_at: tag.updated_at
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,9 +2,15 @@ class ApplicationController < ActionController::Base
|
||||
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
|
||||
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
|
||||
FeatureGuardable, Notifiable
|
||||
include Pundit::Authorization
|
||||
|
||||
include Pagy::Backend
|
||||
|
||||
# Pundit uses current_user by default, but this app uses Current.user
|
||||
def pundit_user
|
||||
Current.user
|
||||
end
|
||||
|
||||
before_action :detect_os
|
||||
before_action :set_default_chat
|
||||
before_action :set_active_storage_url_options
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class HoldingsController < ApplicationController
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
before_action :set_holding, only: %i[show update destroy unlock_cost_basis]
|
||||
|
||||
def index
|
||||
@account = Current.family.accounts.find(params[:account_id])
|
||||
@@ -8,6 +8,31 @@ class HoldingsController < ApplicationController
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
total_cost_basis = holding_params[:cost_basis].to_d
|
||||
|
||||
if total_cost_basis >= 0 && @holding.qty.positive?
|
||||
# Convert total cost basis to per-share cost (the cost_basis field stores per-share)
|
||||
# Zero is valid for gifted/inherited shares
|
||||
per_share_cost = total_cost_basis / @holding.qty
|
||||
@holding.set_manual_cost_basis!(per_share_cost)
|
||||
flash[:notice] = t(".success")
|
||||
else
|
||||
flash[:alert] = t(".error")
|
||||
end
|
||||
|
||||
# Redirect to account page holdings tab to refresh list and close drawer
|
||||
redirect_to account_path(@holding.account, tab: "holdings")
|
||||
end
|
||||
|
||||
def unlock_cost_basis
|
||||
@holding.unlock_cost_basis!
|
||||
flash[:notice] = t(".success")
|
||||
|
||||
# Redirect to account page holdings tab to refresh list and close drawer
|
||||
redirect_to account_path(@holding.account, tab: "holdings")
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @holding.account.can_delete_holdings?
|
||||
@holding.destroy_holding_and_entries!
|
||||
@@ -26,4 +51,8 @@ class HoldingsController < ApplicationController
|
||||
def set_holding
|
||||
@holding = Current.family.holdings.find(params[:id])
|
||||
end
|
||||
|
||||
def holding_params
|
||||
params.require(:holding).permit(:cost_basis)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,6 +37,13 @@ class OidcAccountsController < ApplicationController
|
||||
user
|
||||
)
|
||||
|
||||
# Log account linking
|
||||
SsoAuditLog.log_link!(
|
||||
user: user,
|
||||
provider: @pending_auth["provider"],
|
||||
request: request
|
||||
)
|
||||
|
||||
# Clear pending auth from session
|
||||
session.delete(:pending_oidc_auth)
|
||||
|
||||
@@ -104,15 +111,28 @@ class OidcAccountsController < ApplicationController
|
||||
|
||||
# Create new family for this user
|
||||
@user.family = Family.new
|
||||
@user.role = :admin
|
||||
|
||||
# Use provider-configured default role, or fall back to member (not admin)
|
||||
provider_config = Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == @pending_auth["provider"] }
|
||||
default_role = provider_config&.dig(:settings, :default_role) || "member"
|
||||
@user.role = default_role
|
||||
|
||||
if @user.save
|
||||
# Create the OIDC (or other SSO) identity
|
||||
OidcIdentity.create_from_omniauth(
|
||||
identity = OidcIdentity.create_from_omniauth(
|
||||
build_auth_hash(@pending_auth),
|
||||
@user
|
||||
)
|
||||
|
||||
# Only log JIT account creation if identity was successfully created
|
||||
if identity.persisted?
|
||||
SsoAuditLog.log_jit_account_created!(
|
||||
user: @user,
|
||||
provider: @pending_auth["provider"],
|
||||
request: request
|
||||
)
|
||||
end
|
||||
|
||||
# Clear pending auth from session
|
||||
session.delete(:pending_oidc_auth)
|
||||
|
||||
|
||||
@@ -7,38 +7,7 @@ class ReportsController < ApplicationController
|
||||
before_action :authenticate_for_export, only: :export_transactions
|
||||
|
||||
def index
|
||||
@period_type = params[:period_type]&.to_sym || :monthly
|
||||
@start_date = parse_date_param(:start_date) || default_start_date
|
||||
@end_date = parse_date_param(:end_date) || default_end_date
|
||||
|
||||
# Validate and fix date range if end_date is before start_date
|
||||
validate_and_fix_date_range(show_flash: true)
|
||||
|
||||
# Build the period
|
||||
@period = Period.custom(start_date: @start_date, end_date: @end_date)
|
||||
@previous_period = build_previous_period
|
||||
|
||||
# Get aggregated data
|
||||
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
|
||||
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
|
||||
|
||||
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
|
||||
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
|
||||
|
||||
# Calculate summary metrics
|
||||
@summary_metrics = build_summary_metrics
|
||||
|
||||
# Build trend data (last 6 months)
|
||||
@trends_data = build_trends_data
|
||||
|
||||
# Net worth metrics
|
||||
@net_worth_metrics = build_net_worth_metrics
|
||||
|
||||
# Transactions breakdown
|
||||
@transactions = build_transactions_breakdown
|
||||
|
||||
# Investment metrics (must be before build_reports_sections)
|
||||
@investment_metrics = build_investment_metrics
|
||||
setup_report_data(show_flash: true)
|
||||
|
||||
# Build reports sections for collapsible/reorderable UI
|
||||
@reports_sections = build_reports_sections
|
||||
@@ -46,6 +15,12 @@ class ReportsController < ApplicationController
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Reports", nil ] ]
|
||||
end
|
||||
|
||||
def print
|
||||
setup_report_data(show_flash: false)
|
||||
|
||||
render layout: "print"
|
||||
end
|
||||
|
||||
def update_preferences
|
||||
if Current.user.update_reports_preferences(preferences_params)
|
||||
head :ok
|
||||
@@ -114,6 +89,44 @@ class ReportsController < ApplicationController
|
||||
end
|
||||
|
||||
private
|
||||
def setup_report_data(show_flash: false)
|
||||
@period_type = params[:period_type]&.to_sym || :monthly
|
||||
@start_date = parse_date_param(:start_date) || default_start_date
|
||||
@end_date = parse_date_param(:end_date) || default_end_date
|
||||
|
||||
# Validate and fix date range if end_date is before start_date
|
||||
validate_and_fix_date_range(show_flash: show_flash)
|
||||
|
||||
# Build the period
|
||||
@period = Period.custom(start_date: @start_date, end_date: @end_date)
|
||||
@previous_period = build_previous_period
|
||||
|
||||
# Get aggregated data
|
||||
@current_income_totals = Current.family.income_statement.income_totals(period: @period)
|
||||
@current_expense_totals = Current.family.income_statement.expense_totals(period: @period)
|
||||
|
||||
@previous_income_totals = Current.family.income_statement.income_totals(period: @previous_period)
|
||||
@previous_expense_totals = Current.family.income_statement.expense_totals(period: @previous_period)
|
||||
|
||||
# Calculate summary metrics
|
||||
@summary_metrics = build_summary_metrics
|
||||
|
||||
# Build trend data (last 6 months)
|
||||
@trends_data = build_trends_data
|
||||
|
||||
# Net worth metrics
|
||||
@net_worth_metrics = build_net_worth_metrics
|
||||
|
||||
# Transactions breakdown
|
||||
@transactions = build_transactions_breakdown
|
||||
|
||||
# Investment metrics
|
||||
@investment_metrics = build_investment_metrics
|
||||
|
||||
# Flags for view rendering
|
||||
@has_accounts = Current.family.accounts.any?
|
||||
end
|
||||
|
||||
def preferences_params
|
||||
prefs = params.require(:preferences)
|
||||
{}.tap do |permitted|
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
class SessionsController < ApplicationController
|
||||
before_action :set_session, only: :destroy
|
||||
skip_authentication only: %i[new create openid_connect failure]
|
||||
skip_authentication only: %i[index new create openid_connect failure post_logout]
|
||||
|
||||
layout "auth"
|
||||
|
||||
# Handle GET /sessions (usually from browser back button)
|
||||
def index
|
||||
redirect_to new_session_path
|
||||
end
|
||||
|
||||
def new
|
||||
begin
|
||||
demo = Rails.application.config_for(:demo)
|
||||
@@ -62,7 +67,40 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
user = Current.user
|
||||
id_token = session[:id_token_hint]
|
||||
login_provider = session[:sso_login_provider]
|
||||
|
||||
# Find the identity for the provider used during login, with fallback to first if session data lost
|
||||
oidc_identity = if login_provider.present?
|
||||
user.oidc_identities.find_by(provider: login_provider)
|
||||
else
|
||||
user.oidc_identities.first
|
||||
end
|
||||
|
||||
# Destroy local session
|
||||
@session.destroy
|
||||
session.delete(:id_token_hint)
|
||||
session.delete(:sso_login_provider)
|
||||
|
||||
# Check if we should redirect to IdP for federated logout
|
||||
if oidc_identity && id_token.present?
|
||||
idp_logout_url = build_idp_logout_url(oidc_identity, id_token)
|
||||
|
||||
if idp_logout_url
|
||||
SsoAuditLog.log_logout_idp!(user: user, provider: oidc_identity.provider, request: request)
|
||||
redirect_to idp_logout_url, allow_other_host: true
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# Standard local logout
|
||||
SsoAuditLog.log_logout!(user: user, request: request)
|
||||
redirect_to new_session_path, notice: t(".logout_successful")
|
||||
end
|
||||
|
||||
# Handle redirect back from IdP after federated logout
|
||||
def post_logout
|
||||
redirect_to new_session_path, notice: t(".logout_successful")
|
||||
end
|
||||
|
||||
@@ -82,6 +120,14 @@ class SessionsController < ApplicationController
|
||||
# Existing OIDC identity found - authenticate the user
|
||||
user = oidc_identity.user
|
||||
oidc_identity.record_authentication!
|
||||
oidc_identity.sync_user_attributes!(auth)
|
||||
|
||||
# Store id_token and provider for RP-initiated logout
|
||||
session[:id_token_hint] = auth.credentials&.id_token if auth.credentials&.id_token
|
||||
session[:sso_login_provider] = auth.provider
|
||||
|
||||
# Log successful SSO login
|
||||
SsoAuditLog.log_login!(user: user, provider: auth.provider, request: request)
|
||||
|
||||
# MFA check: If user has MFA enabled, require verification
|
||||
if user.otp_required?
|
||||
@@ -107,7 +153,27 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def failure
|
||||
redirect_to new_session_path, alert: t("sessions.failure.failed")
|
||||
# Sanitize reason to known values only
|
||||
known_reasons = %w[sso_provider_unavailable sso_invalid_response sso_failed]
|
||||
sanitized_reason = known_reasons.include?(params[:message]) ? params[:message] : "sso_failed"
|
||||
|
||||
# Log failed SSO attempt
|
||||
SsoAuditLog.log_login_failed!(
|
||||
provider: params[:strategy],
|
||||
request: request,
|
||||
reason: sanitized_reason
|
||||
)
|
||||
|
||||
message = case sanitized_reason
|
||||
when "sso_provider_unavailable"
|
||||
t("sessions.failure.sso_provider_unavailable")
|
||||
when "sso_invalid_response"
|
||||
t("sessions.failure.sso_invalid_response")
|
||||
else
|
||||
t("sessions.failure.sso_failed")
|
||||
end
|
||||
|
||||
redirect_to new_session_path, alert: message
|
||||
end
|
||||
|
||||
private
|
||||
@@ -130,4 +196,53 @@ class SessionsController < ApplicationController
|
||||
|
||||
demo["hosts"].include?(request.host)
|
||||
end
|
||||
|
||||
def build_idp_logout_url(oidc_identity, id_token)
|
||||
# Find the provider configuration using unified loader (supports both YAML and DB providers)
|
||||
provider_config = ProviderLoader.load_providers.find do |p|
|
||||
p[:name] == oidc_identity.provider
|
||||
end
|
||||
|
||||
return nil unless provider_config
|
||||
|
||||
# For OIDC providers, fetch end_session_endpoint from discovery
|
||||
if provider_config[:strategy] == "openid_connect" && provider_config[:issuer].present?
|
||||
begin
|
||||
discovery_url = discovery_url_for(provider_config[:issuer])
|
||||
response = Faraday.get(discovery_url) do |req|
|
||||
req.options.timeout = 5
|
||||
req.options.open_timeout = 3
|
||||
end
|
||||
|
||||
return nil unless response.success?
|
||||
|
||||
discovery = JSON.parse(response.body)
|
||||
end_session_endpoint = discovery["end_session_endpoint"]
|
||||
|
||||
return nil unless end_session_endpoint.present?
|
||||
|
||||
# Build the logout URL with post_logout_redirect_uri
|
||||
post_logout_redirect = "#{request.base_url}/auth/logout/callback"
|
||||
params = {
|
||||
id_token_hint: id_token,
|
||||
post_logout_redirect_uri: post_logout_redirect
|
||||
}
|
||||
|
||||
"#{end_session_endpoint}?#{params.to_query}"
|
||||
rescue Faraday::Error, JSON::ParserError, StandardError => e
|
||||
Rails.logger.warn("[SSO] Failed to fetch OIDC discovery for logout: #{e.message}")
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def discovery_url_for(issuer)
|
||||
if issuer.end_with?("/")
|
||||
"#{issuer}.well-known/openid-configuration"
|
||||
else
|
||||
"#{issuer}/.well-known/openid-configuration"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,6 +58,33 @@ class Settings::HostingsController < ApplicationController
|
||||
Setting.securities_provider = hosting_params[:securities_provider]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:syncs_include_pending)
|
||||
Setting.syncs_include_pending = hosting_params[:syncs_include_pending] == "1"
|
||||
end
|
||||
|
||||
sync_settings_changed = false
|
||||
|
||||
if hosting_params.key?(:auto_sync_enabled)
|
||||
Setting.auto_sync_enabled = hosting_params[:auto_sync_enabled] == "1"
|
||||
sync_settings_changed = true
|
||||
end
|
||||
|
||||
if hosting_params.key?(:auto_sync_time)
|
||||
time_value = hosting_params[:auto_sync_time]
|
||||
unless Setting.valid_auto_sync_time?(time_value)
|
||||
flash[:alert] = t(".invalid_sync_time")
|
||||
return redirect_to settings_hosting_path
|
||||
end
|
||||
|
||||
Setting.auto_sync_time = time_value
|
||||
Setting.auto_sync_timezone = current_user_timezone
|
||||
sync_settings_changed = true
|
||||
end
|
||||
|
||||
if sync_settings_changed
|
||||
sync_auto_sync_scheduler!
|
||||
end
|
||||
|
||||
if hosting_params.key?(:openai_access_token)
|
||||
token_param = hosting_params[:openai_access_token].to_s.strip
|
||||
# Ignore blanks and redaction placeholders to prevent accidental overwrite
|
||||
@@ -99,10 +126,22 @@ class Settings::HostingsController < ApplicationController
|
||||
|
||||
private
|
||||
def hosting_params
|
||||
params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :twelve_data_api_key, :openai_access_token, :openai_uri_base, :openai_model, :openai_json_mode, :exchange_rate_provider, :securities_provider)
|
||||
params.require(:setting).permit(:onboarding_state, :require_email_confirmation, :brand_fetch_client_id, :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)
|
||||
end
|
||||
|
||||
def ensure_admin
|
||||
redirect_to settings_hosting_path, alert: t(".not_authorized") unless Current.user.admin?
|
||||
end
|
||||
|
||||
def sync_auto_sync_scheduler!
|
||||
AutoSyncScheduler.sync!
|
||||
rescue StandardError => error
|
||||
Rails.logger.error("[AutoSyncScheduler] Failed to sync scheduler: #{error.message}")
|
||||
Rails.logger.error(error.backtrace.join("\n"))
|
||||
flash[:alert] = t(".scheduler_sync_failed")
|
||||
end
|
||||
|
||||
def current_user_timezone
|
||||
Current.family&.timezone.presence || "UTC"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,5 +6,6 @@ class Settings::SecuritiesController < ApplicationController
|
||||
[ "Home", root_path ],
|
||||
[ "Security", nil ]
|
||||
]
|
||||
@oidc_identities = Current.user.oidc_identities.order(:provider)
|
||||
end
|
||||
end
|
||||
|
||||
27
app/controllers/settings/sso_identities_controller.rb
Normal file
27
app/controllers/settings/sso_identities_controller.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Settings::SsoIdentitiesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def destroy
|
||||
@identity = Current.user.oidc_identities.find(params[:id])
|
||||
|
||||
# Prevent unlinking last identity if user has no password
|
||||
if Current.user.oidc_identities.count == 1 && Current.user.password_digest.blank?
|
||||
redirect_to settings_security_path, alert: t(".cannot_unlink_last")
|
||||
return
|
||||
end
|
||||
|
||||
provider_name = @identity.provider
|
||||
@identity.destroy!
|
||||
|
||||
# Log account unlinking
|
||||
SsoAuditLog.log_unlink!(
|
||||
user: Current.user,
|
||||
provider: provider_name,
|
||||
request: request
|
||||
)
|
||||
|
||||
redirect_to settings_security_path, notice: t(".success", provider: provider_name)
|
||||
end
|
||||
end
|
||||
@@ -24,9 +24,14 @@ class TransactionCategoriesController < ApplicationController
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
dom_id(transaction, :category_menu),
|
||||
partial: "categories/menu",
|
||||
locals: { transaction: transaction }
|
||||
dom_id(transaction, "category_menu_mobile"),
|
||||
partial: "transactions/transaction_category",
|
||||
locals: { transaction: transaction, variant: "mobile" }
|
||||
),
|
||||
turbo_stream.replace(
|
||||
dom_id(transaction, "category_menu_desktop"),
|
||||
partial: "transactions/transaction_category",
|
||||
locals: { transaction: transaction, variant: "desktop" }
|
||||
),
|
||||
turbo_stream.replace(
|
||||
"category_name_mobile_#{transaction.id}",
|
||||
|
||||
@@ -116,6 +116,38 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def merge_duplicate
|
||||
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
|
||||
|
||||
if transaction.merge_with_duplicate!
|
||||
flash[:notice] = t("transactions.merge_duplicate.success")
|
||||
else
|
||||
flash[:alert] = t("transactions.merge_duplicate.failure")
|
||||
end
|
||||
|
||||
redirect_to transactions_path
|
||||
rescue ActiveRecord::RecordNotDestroyed, ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error("Failed to merge duplicate transaction #{params[:id]}: #{e.message}")
|
||||
flash[:alert] = t("transactions.merge_duplicate.failure")
|
||||
redirect_to transactions_path
|
||||
end
|
||||
|
||||
def dismiss_duplicate
|
||||
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
|
||||
|
||||
if transaction.dismiss_duplicate_suggestion!
|
||||
flash[:notice] = t("transactions.dismiss_duplicate.success")
|
||||
else
|
||||
flash[:alert] = t("transactions.dismiss_duplicate.failure")
|
||||
end
|
||||
|
||||
redirect_back_or_to transactions_path
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error("Failed to dismiss duplicate suggestion for transaction #{params[:id]}: #{e.message}")
|
||||
flash[:alert] = t("transactions.dismiss_duplicate.failure")
|
||||
redirect_back_or_to transactions_path
|
||||
end
|
||||
|
||||
def mark_as_recurring
|
||||
transaction = Current.family.transactions.includes(entry: :account).find(params[:id])
|
||||
|
||||
@@ -186,7 +218,7 @@ class TransactionsController < ApplicationController
|
||||
def entry_params
|
||||
entry_params = params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, { tag_ids: [] } ]
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, :kind, :investment_activity_label, { tag_ids: [] } ]
|
||||
)
|
||||
|
||||
nature = entry_params.delete(:nature)
|
||||
@@ -205,7 +237,7 @@ class TransactionsController < ApplicationController
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, :active_accounts_only,
|
||||
accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
categories: [], merchants: [], types: [], tags: [], status: []
|
||||
)
|
||||
.to_h
|
||||
.compact_blank
|
||||
|
||||
@@ -58,7 +58,7 @@ module LanguagesHelper
|
||||
uk: "Ukrainian",
|
||||
vi: "Vietnamese",
|
||||
'zh-CN': "简体中文",
|
||||
'zh-TW': "Chinese (Traditional)",
|
||||
'zh-TW': "繁體中文",
|
||||
af: "Afrikaans",
|
||||
az: "Azerbaijani",
|
||||
be: "Belarusian",
|
||||
@@ -163,7 +163,8 @@ module LanguagesHelper
|
||||
"ca", # Catalan - 57 translation files
|
||||
"ro", # Romanian - 62 translation files
|
||||
"pt-BR", # Brazilian Portuguese - 60 translation files
|
||||
"zh-CN" # Chinese (Simplified) - 59 translation files
|
||||
"zh-CN", # Chinese (Simplified) - 59 translation files
|
||||
"zh-TW" # Chinese (Traditional) - 63 translation files
|
||||
].freeze
|
||||
|
||||
COUNTRY_MAPPING = {
|
||||
|
||||
34
app/helpers/reports_helper.rb
Normal file
34
app/helpers/reports_helper.rb
Normal file
@@ -0,0 +1,34 @@
|
||||
module ReportsHelper
|
||||
# Generate SVG polyline points for a sparkline chart
|
||||
# Returns empty string if fewer than 2 data points (can't draw a line with 1 point)
|
||||
def sparkline_points(values, width: 60, height: 16)
|
||||
return "" if values.nil? || values.length < 2 || values.all? { |v| v.nil? || v.zero? }
|
||||
|
||||
nums = values.map(&:to_f)
|
||||
max_val = nums.max
|
||||
min_val = nums.min
|
||||
range = max_val - min_val
|
||||
range = 1.0 if range.zero?
|
||||
|
||||
points = nums.each_with_index.map do |val, i|
|
||||
x = (i.to_f / [ nums.length - 1, 1 ].max) * width
|
||||
y = height - ((val - min_val) / range * (height - 2)) - 1
|
||||
"#{x.round(1)},#{y.round(1)}"
|
||||
end
|
||||
|
||||
points.join(" ")
|
||||
end
|
||||
|
||||
# Calculate cumulative net values from trends data
|
||||
def cumulative_net_values(trends)
|
||||
return [] if trends.nil?
|
||||
|
||||
running = 0
|
||||
trends.map { |t| running += t[:net].to_i; running }
|
||||
end
|
||||
|
||||
# Check if trends data has enough points for sparklines (need at least 2)
|
||||
def has_sparkline_data?(trends_data)
|
||||
trends_data&.length.to_i >= 2
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,14 @@
|
||||
module TransactionsHelper
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", label: "Account", icon: "layers" },
|
||||
{ key: "date_filter", label: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", label: "Type", icon: "tag" },
|
||||
{ key: "amount_filter", label: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", label: "Category", icon: "shapes" },
|
||||
{ key: "tag_filter", label: "Tag", icon: "tags" },
|
||||
{ key: "merchant_filter", label: "Merchant", icon: "store" }
|
||||
{ key: "account_filter", label: t("transactions.search.filters.account"), icon: "layers" },
|
||||
{ key: "date_filter", label: t("transactions.search.filters.date"), icon: "calendar" },
|
||||
{ key: "type_filter", label: t("transactions.search.filters.type"), icon: "tag" },
|
||||
{ key: "status_filter", label: t("transactions.search.filters.status"), icon: "clock" },
|
||||
{ key: "amount_filter", label: t("transactions.search.filters.amount"), icon: "hash" },
|
||||
{ key: "category_filter", label: t("transactions.search.filters.category"), icon: "shapes" },
|
||||
{ key: "tag_filter", label: t("transactions.search.filters.tag"), icon: "tags" },
|
||||
{ key: "merchant_filter", label: t("transactions.search.filters.merchant"), icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
226
app/javascript/controllers/admin_sso_form_controller.js
Normal file
226
app/javascript/controllers/admin_sso_form_controller.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Connects to data-controller="admin-sso-form"
|
||||
export default class extends Controller {
|
||||
static targets = ["callbackUrl", "testResult", "samlCallbackUrl"]
|
||||
|
||||
connect() {
|
||||
// Initialize field visibility on page load
|
||||
this.toggleFields()
|
||||
// Initialize callback URL
|
||||
this.updateCallbackUrl()
|
||||
}
|
||||
|
||||
updateCallbackUrl() {
|
||||
const nameInput = this.element.querySelector('input[name*="[name]"]')
|
||||
const callbackDisplay = this.callbackUrlTarget
|
||||
|
||||
if (!nameInput || !callbackDisplay) return
|
||||
|
||||
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
|
||||
const baseUrl = window.location.origin
|
||||
callbackDisplay.textContent = `${baseUrl}/auth/${providerName}/callback`
|
||||
}
|
||||
|
||||
toggleFields() {
|
||||
const strategySelect = this.element.querySelector('select[name*="[strategy]"]')
|
||||
if (!strategySelect) return
|
||||
|
||||
const strategy = strategySelect.value
|
||||
const isOidc = strategy === "openid_connect"
|
||||
const isSaml = strategy === "saml"
|
||||
|
||||
// Toggle OIDC fields
|
||||
const oidcFields = this.element.querySelectorAll('[data-oidc-field]')
|
||||
oidcFields.forEach(field => {
|
||||
if (isOidc) {
|
||||
field.classList.remove('hidden')
|
||||
} else {
|
||||
field.classList.add('hidden')
|
||||
}
|
||||
})
|
||||
|
||||
// Toggle SAML fields
|
||||
const samlFields = this.element.querySelectorAll('[data-saml-field]')
|
||||
samlFields.forEach(field => {
|
||||
if (isSaml) {
|
||||
field.classList.remove('hidden')
|
||||
} else {
|
||||
field.classList.add('hidden')
|
||||
}
|
||||
})
|
||||
|
||||
// Update SAML callback URL if present
|
||||
if (this.hasSamlCallbackUrlTarget) {
|
||||
this.updateSamlCallbackUrl()
|
||||
}
|
||||
}
|
||||
|
||||
updateSamlCallbackUrl() {
|
||||
const nameInput = this.element.querySelector('input[name*="[name]"]')
|
||||
if (!nameInput || !this.hasSamlCallbackUrlTarget) return
|
||||
|
||||
const providerName = nameInput.value.trim() || 'PROVIDER_NAME'
|
||||
const baseUrl = window.location.origin
|
||||
this.samlCallbackUrlTarget.textContent = `${baseUrl}/auth/${providerName}/callback`
|
||||
}
|
||||
|
||||
copySamlCallback(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!this.hasSamlCallbackUrlTarget) return
|
||||
|
||||
const callbackUrl = this.samlCallbackUrlTarget.textContent
|
||||
|
||||
navigator.clipboard.writeText(callbackUrl).then(() => {
|
||||
const button = event.currentTarget
|
||||
const originalText = button.innerHTML
|
||||
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
|
||||
button.classList.add('text-green-600')
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText
|
||||
button.classList.remove('text-green-600')
|
||||
}, 2000)
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err)
|
||||
alert('Failed to copy to clipboard')
|
||||
})
|
||||
}
|
||||
|
||||
async validateIssuer(event) {
|
||||
const issuerInput = event.target
|
||||
const issuer = issuerInput.value.trim()
|
||||
|
||||
if (!issuer) return
|
||||
|
||||
try {
|
||||
// Construct discovery URL
|
||||
const discoveryUrl = issuer.endsWith('/')
|
||||
? `${issuer}.well-known/openid-configuration`
|
||||
: `${issuer}/.well-known/openid-configuration`
|
||||
|
||||
// Show loading state
|
||||
issuerInput.classList.add('border-yellow-300')
|
||||
|
||||
const response = await fetch(discoveryUrl, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.issuer) {
|
||||
// Valid OIDC discovery endpoint
|
||||
issuerInput.classList.remove('border-yellow-300', 'border-red-300')
|
||||
issuerInput.classList.add('border-green-300')
|
||||
this.showValidationMessage(issuerInput, 'Valid OIDC issuer', 'success')
|
||||
} else {
|
||||
throw new Error('Invalid discovery response')
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Discovery endpoint returned ${response.status}`)
|
||||
}
|
||||
} catch (error) {
|
||||
// CORS errors are expected when validating from browser - show as warning not error
|
||||
issuerInput.classList.remove('border-yellow-300', 'border-green-300')
|
||||
issuerInput.classList.add('border-amber-300')
|
||||
this.showValidationMessage(issuerInput, "Could not validate from browser (CORS). Provider can still be saved.", 'warning')
|
||||
}
|
||||
}
|
||||
|
||||
copyCallback(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const callbackDisplay = this.callbackUrlTarget
|
||||
if (!callbackDisplay) return
|
||||
|
||||
const callbackUrl = callbackDisplay.textContent
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(callbackUrl).then(() => {
|
||||
// Show success feedback
|
||||
const button = event.currentTarget
|
||||
const originalText = button.innerHTML
|
||||
button.innerHTML = '<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> Copied!'
|
||||
button.classList.add('text-green-600')
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText
|
||||
button.classList.remove('text-green-600')
|
||||
}, 2000)
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err)
|
||||
alert('Failed to copy to clipboard')
|
||||
})
|
||||
}
|
||||
|
||||
showValidationMessage(input, message, type) {
|
||||
// Remove any existing validation message
|
||||
const existingMessage = input.parentElement.querySelector('.validation-message')
|
||||
if (existingMessage) {
|
||||
existingMessage.remove()
|
||||
}
|
||||
|
||||
// Create new validation message
|
||||
const messageEl = document.createElement('p')
|
||||
const colorClass = type === 'success' ? 'text-green-600' : type === 'warning' ? 'text-amber-600' : 'text-red-600'
|
||||
messageEl.className = `validation-message mt-1 text-sm ${colorClass}`
|
||||
messageEl.textContent = message
|
||||
|
||||
input.parentElement.appendChild(messageEl)
|
||||
|
||||
// Auto-remove after 5 seconds (except warnings which stay)
|
||||
if (type !== 'warning') {
|
||||
setTimeout(() => {
|
||||
messageEl.remove()
|
||||
input.classList.remove('border-green-300', 'border-red-300', 'border-amber-300')
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(event) {
|
||||
const button = event.currentTarget
|
||||
const testUrl = button.dataset.adminSsoFormTestUrlValue
|
||||
const resultEl = this.testResultTarget
|
||||
|
||||
if (!testUrl) return
|
||||
|
||||
// Show loading state
|
||||
button.disabled = true
|
||||
button.textContent = 'Testing...'
|
||||
resultEl.textContent = ''
|
||||
resultEl.className = 'ml-2 text-sm'
|
||||
|
||||
try {
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
resultEl.textContent = `✓ ${data.message}`
|
||||
resultEl.classList.add('text-green-600')
|
||||
} else {
|
||||
resultEl.textContent = `✗ ${data.message}`
|
||||
resultEl.classList.add('text-red-600')
|
||||
}
|
||||
|
||||
// Show details in console for debugging
|
||||
if (data.details && Object.keys(data.details).length > 0) {
|
||||
console.log('SSO Test Connection Details:', data.details)
|
||||
}
|
||||
} catch (error) {
|
||||
resultEl.textContent = `✗ Request failed: ${error.message}`
|
||||
resultEl.classList.add('text-red-600')
|
||||
} finally {
|
||||
button.disabled = false
|
||||
button.textContent = 'Test Connection'
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/javascript/controllers/cost_basis_form_controller.js
Normal file
30
app/javascript/controllers/cost_basis_form_controller.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Handles bidirectional conversion between total cost basis and per-share cost
|
||||
// in the manual cost basis entry form.
|
||||
export default class extends Controller {
|
||||
static targets = ["total", "perShare", "perShareValue"]
|
||||
static values = { qty: Number }
|
||||
|
||||
// Called when user types in the total cost basis field
|
||||
// Updates the per-share display and input to show the calculated value
|
||||
updatePerShare() {
|
||||
const total = Number.parseFloat(this.totalTarget.value) || 0
|
||||
const qty = this.qtyValue || 1
|
||||
const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00"
|
||||
this.perShareValueTarget.textContent = perShare
|
||||
if (this.hasPerShareTarget) {
|
||||
this.perShareTarget.value = perShare
|
||||
}
|
||||
}
|
||||
|
||||
// Called when user types in the per-share field
|
||||
// Updates the total cost basis field with the calculated value
|
||||
updateTotal() {
|
||||
const perShare = Number.parseFloat(this.perShareTarget.value) || 0
|
||||
const qty = this.qtyValue || 1
|
||||
const total = (perShare * qty).toFixed(2)
|
||||
this.totalTarget.value = total
|
||||
this.perShareValueTarget.textContent = perShare.toFixed(2)
|
||||
}
|
||||
}
|
||||
33
app/javascript/controllers/drawer_cost_basis_controller.js
Normal file
33
app/javascript/controllers/drawer_cost_basis_controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Handles the inline cost basis editor in the holding drawer.
|
||||
// Shows/hides the form and handles bidirectional total <-> per-share conversion.
|
||||
export default class extends Controller {
|
||||
static targets = ["form", "total", "perShare", "perShareValue"]
|
||||
static values = { qty: Number }
|
||||
|
||||
toggle(event) {
|
||||
event.preventDefault()
|
||||
this.formTarget.classList.toggle("hidden")
|
||||
}
|
||||
|
||||
// Called when user types in total cost basis field
|
||||
updatePerShare() {
|
||||
const total = Number.parseFloat(this.totalTarget.value) || 0
|
||||
const qty = this.qtyValue || 1
|
||||
const perShare = qty > 0 ? (total / qty).toFixed(2) : "0.00"
|
||||
this.perShareValueTarget.textContent = perShare
|
||||
if (this.hasPerShareTarget) {
|
||||
this.perShareTarget.value = perShare
|
||||
}
|
||||
}
|
||||
|
||||
// Called when user types in per-share field
|
||||
updateTotal() {
|
||||
const perShare = Number.parseFloat(this.perShareTarget.value) || 0
|
||||
const qty = this.qtyValue || 1
|
||||
const total = (perShare * qty).toFixed(2)
|
||||
this.totalTarget.value = total
|
||||
this.perShareValueTarget.textContent = perShare.toFixed(2)
|
||||
}
|
||||
}
|
||||
@@ -2,166 +2,30 @@ class SimplefinConnectionUpdateJob < ApplicationJob
|
||||
queue_as :high_priority
|
||||
|
||||
# Disable automatic retries for this job since the setup token is single-use.
|
||||
# If the token claim succeeds but import fails, retrying would fail at claim.
|
||||
# If the token claim succeeds but sync fails, retrying would fail at claim.
|
||||
discard_on Provider::Simplefin::SimplefinError do |job, error|
|
||||
Rails.logger.error(
|
||||
"SimplefinConnectionUpdateJob discarded: #{error.class} - #{error.message} " \
|
||||
"(family_id=#{job.arguments.first[:family_id]}, old_item_id=#{job.arguments.first[:old_simplefin_item_id]})"
|
||||
"(family_id=#{job.arguments.first[:family_id]}, item_id=#{job.arguments.first[:old_simplefin_item_id]})"
|
||||
)
|
||||
end
|
||||
|
||||
def perform(family_id:, old_simplefin_item_id:, setup_token:)
|
||||
family = Family.find(family_id)
|
||||
old_item = family.simplefin_items.find(old_simplefin_item_id)
|
||||
simplefin_item = family.simplefin_items.find(old_simplefin_item_id)
|
||||
|
||||
# Step 1: Claim the token and create the new item.
|
||||
# This is the critical step - if it fails, we can safely retry.
|
||||
# If it succeeds, the token is consumed and we must not retry the claim.
|
||||
updated_item = family.create_simplefin_item!(
|
||||
setup_token: setup_token,
|
||||
item_name: old_item.name
|
||||
# Step 1: Claim the new token and update the existing item's access_url.
|
||||
# This preserves all existing account linkages - no need to transfer anything.
|
||||
simplefin_item.update_access_url!(setup_token: setup_token)
|
||||
|
||||
# Step 2: Sync the item to import fresh data.
|
||||
# The existing repair_stale_linkages logic handles cases where SimpleFIN
|
||||
# account IDs changed (e.g., user re-added institution in SimpleFIN Bridge).
|
||||
simplefin_item.sync_later
|
||||
|
||||
Rails.logger.info(
|
||||
"SimplefinConnectionUpdateJob: Successfully updated SimplefinItem #{simplefin_item.id} " \
|
||||
"with new access_url for family #{family_id}"
|
||||
)
|
||||
|
||||
# Step 2: Import accounts from SimpleFin.
|
||||
# If this fails, we have an orphaned item but the token is already consumed.
|
||||
# We handle this gracefully by marking the item and continuing.
|
||||
begin
|
||||
updated_item.import_latest_simplefin_data
|
||||
rescue => e
|
||||
Rails.logger.error(
|
||||
"SimplefinConnectionUpdateJob: import failed for new item #{updated_item.id}: " \
|
||||
"#{e.class} - #{e.message}. Item created but may need manual sync."
|
||||
)
|
||||
# Mark the item as needing attention but don't fail the job entirely.
|
||||
# The item exists and can be synced manually later.
|
||||
updated_item.update!(status: :requires_update)
|
||||
# Still proceed to transfer accounts and schedule old item deletion
|
||||
end
|
||||
|
||||
# Step 3: Transfer account links from old to new item.
|
||||
# This is idempotent and safe to retry.
|
||||
# Check for linked accounts via BOTH legacy FK and AccountProvider.
|
||||
ActiveRecord::Base.transaction do
|
||||
old_item.simplefin_accounts.includes(:account, account_provider: :account).each do |old_account|
|
||||
# Get the linked account via either system
|
||||
linked_account = old_account.current_account
|
||||
next unless linked_account.present?
|
||||
|
||||
new_simplefin_account = find_matching_simplefin_account(old_account, updated_item.simplefin_accounts)
|
||||
next unless new_simplefin_account
|
||||
|
||||
# Update legacy FK
|
||||
linked_account.update!(simplefin_account_id: new_simplefin_account.id)
|
||||
|
||||
# Also migrate AccountProvider if it exists
|
||||
if old_account.account_provider.present?
|
||||
old_account.account_provider.update!(
|
||||
provider_type: "SimplefinAccount",
|
||||
provider_id: new_simplefin_account.id
|
||||
)
|
||||
else
|
||||
# Create AccountProvider for consistency
|
||||
new_simplefin_account.ensure_account_provider!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Schedule deletion outside transaction to avoid race condition where
|
||||
# the job is enqueued even if the transaction rolls back
|
||||
old_item.destroy_later
|
||||
|
||||
# Only mark as good if import succeeded (status wasn't set to requires_update above)
|
||||
updated_item.update!(status: :good) unless updated_item.requires_update?
|
||||
end
|
||||
|
||||
private
|
||||
# Find a matching SimpleFin account in the new item's accounts.
|
||||
# Uses a multi-tier matching strategy:
|
||||
# 1. Exact account_id match (preferred)
|
||||
# 2. Fingerprint match (name + institution + account_type)
|
||||
# 3. Fuzzy name match with same institution (fallback)
|
||||
def find_matching_simplefin_account(old_account, new_accounts)
|
||||
exact_match = new_accounts.find_by(account_id: old_account.account_id)
|
||||
return exact_match if exact_match
|
||||
|
||||
old_fingerprint = account_fingerprint(old_account)
|
||||
fingerprint_match = new_accounts.find { |new_account| account_fingerprint(new_account) == old_fingerprint }
|
||||
return fingerprint_match if fingerprint_match
|
||||
|
||||
old_institution = extract_institution_id(old_account)
|
||||
old_name_normalized = normalize_account_name(old_account.name)
|
||||
|
||||
new_accounts.find do |new_account|
|
||||
new_institution = extract_institution_id(new_account)
|
||||
new_name_normalized = normalize_account_name(new_account.name)
|
||||
|
||||
next false unless old_institution.present? && old_institution == new_institution
|
||||
|
||||
names_similar?(old_name_normalized, new_name_normalized)
|
||||
end
|
||||
end
|
||||
|
||||
def account_fingerprint(simplefin_account)
|
||||
institution_id = extract_institution_id(simplefin_account)
|
||||
name_normalized = normalize_account_name(simplefin_account.name)
|
||||
account_type = simplefin_account.account_type.to_s.downcase
|
||||
|
||||
"#{institution_id}:#{name_normalized}:#{account_type}"
|
||||
end
|
||||
|
||||
def extract_institution_id(simplefin_account)
|
||||
org_data = simplefin_account.org_data
|
||||
return nil unless org_data.is_a?(Hash)
|
||||
|
||||
org_data["id"] || org_data["domain"] || org_data["name"]&.downcase&.gsub(/\s+/, "_")
|
||||
end
|
||||
|
||||
def normalize_account_name(name)
|
||||
return "" if name.blank?
|
||||
|
||||
name.to_s
|
||||
.downcase
|
||||
.gsub(/[^a-z0-9]/, "")
|
||||
end
|
||||
|
||||
def names_similar?(name1, name2)
|
||||
return false if name1.blank? || name2.blank?
|
||||
|
||||
return true if name1 == name2
|
||||
return true if name1.include?(name2) || name2.include?(name1)
|
||||
|
||||
longer = [ name1.length, name2.length ].max
|
||||
return false if longer == 0
|
||||
|
||||
# Use Levenshtein distance for more accurate similarity
|
||||
distance = levenshtein_distance(name1, name2)
|
||||
similarity = 1.0 - (distance.to_f / longer)
|
||||
similarity >= 0.8
|
||||
end
|
||||
|
||||
# Compute Levenshtein edit distance between two strings
|
||||
def levenshtein_distance(s1, s2)
|
||||
m, n = s1.length, s2.length
|
||||
return n if m.zero?
|
||||
return m if n.zero?
|
||||
|
||||
# Use a single array and update in place for memory efficiency
|
||||
prev_row = (0..n).to_a
|
||||
curr_row = []
|
||||
|
||||
(1..m).each do |i|
|
||||
curr_row[0] = i
|
||||
(1..n).each do |j|
|
||||
cost = s1[i - 1] == s2[j - 1] ? 0 : 1
|
||||
curr_row[j] = [
|
||||
prev_row[j] + 1, # deletion
|
||||
curr_row[j - 1] + 1, # insertion
|
||||
prev_row[j - 1] + cost # substitution
|
||||
].min
|
||||
end
|
||||
prev_row, curr_row = curr_row, prev_row
|
||||
end
|
||||
|
||||
prev_row[n]
|
||||
end
|
||||
end
|
||||
|
||||
29
app/middleware/omniauth_error_handler.rb
Normal file
29
app/middleware/omniauth_error_handler.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Middleware to catch OmniAuth/OIDC errors and redirect gracefully
|
||||
# instead of showing ugly error pages
|
||||
class OmniauthErrorHandler
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
@app.call(env)
|
||||
rescue OpenIDConnect::Discovery::DiscoveryFailed => e
|
||||
Rails.logger.error("[OmniAuth] OIDC Discovery failed: #{e.message}")
|
||||
redirect_to_failure(env, "sso_provider_unavailable")
|
||||
rescue OmniAuth::Error => e
|
||||
Rails.logger.error("[OmniAuth] Authentication error: #{e.message}")
|
||||
redirect_to_failure(env, "sso_failed")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def redirect_to_failure(env, message)
|
||||
[
|
||||
302,
|
||||
{ "Location" => "/auth/failure?message=#{message}", "Content-Type" => "text/html" },
|
||||
[ "Redirecting..." ]
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -16,9 +16,11 @@ class Account::ProviderImportAdapter
|
||||
# @param category_id [Integer, nil] Optional category ID
|
||||
# @param merchant [Merchant, nil] Optional merchant object
|
||||
# @param notes [String, nil] Optional transaction notes/memo
|
||||
# @param pending_transaction_id [String, nil] Plaid's linking ID for pending→posted reconciliation
|
||||
# @param extra [Hash, nil] Optional provider-specific metadata to merge into transaction.extra
|
||||
# @param investment_activity_label [String, nil] Optional activity type label (e.g., "Buy", "Dividend")
|
||||
# @return [Entry] The created or updated entry
|
||||
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, extra: nil)
|
||||
def import_transaction(external_id:, amount:, currency:, date:, name:, source:, category_id: nil, merchant: nil, notes: nil, pending_transaction_id: nil, extra: nil, investment_activity_label: nil)
|
||||
raise ArgumentError, "external_id is required" if external_id.blank?
|
||||
raise ArgumentError, "source is required" if source.blank?
|
||||
|
||||
@@ -43,6 +45,42 @@ class Account::ProviderImportAdapter
|
||||
end
|
||||
end
|
||||
|
||||
# If still a new entry and this is a POSTED transaction, check for matching pending transactions
|
||||
incoming_pending = extra.is_a?(Hash) && (
|
||||
ActiveModel::Type::Boolean.new.cast(extra.dig("simplefin", "pending")) ||
|
||||
ActiveModel::Type::Boolean.new.cast(extra.dig("plaid", "pending"))
|
||||
)
|
||||
|
||||
if entry.new_record? && !incoming_pending
|
||||
pending_match = nil
|
||||
|
||||
# PRIORITY 1: Use Plaid's pending_transaction_id if provided (most reliable)
|
||||
# Plaid explicitly links pending→posted with this ID - no guessing required
|
||||
if pending_transaction_id.present?
|
||||
pending_match = account.entries.find_by(external_id: pending_transaction_id, source: source)
|
||||
if pending_match
|
||||
Rails.logger.info("Reconciling pending→posted via Plaid pending_transaction_id: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}")
|
||||
end
|
||||
end
|
||||
|
||||
# PRIORITY 2: Fallback to EXACT amount match (for SimpleFIN and providers without linking IDs)
|
||||
# Only searches backward in time - pending date must be <= posted date
|
||||
if pending_match.nil?
|
||||
pending_match = find_pending_transaction(date: date, amount: amount, currency: currency, source: source)
|
||||
if pending_match
|
||||
Rails.logger.info("Reconciling pending→posted via exact amount match: claiming entry #{pending_match.id} (#{pending_match.name}) with new external_id #{external_id}")
|
||||
end
|
||||
end
|
||||
|
||||
if pending_match
|
||||
entry = pending_match
|
||||
entry.assign_attributes(external_id: external_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Track if this is a new posted transaction (for fuzzy suggestion after save)
|
||||
is_new_posted = entry.new_record? && !incoming_pending
|
||||
|
||||
# Validate entryable type matches to prevent external_id collisions
|
||||
if entry.persisted? && !entry.entryable.is_a?(Transaction)
|
||||
raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}"
|
||||
@@ -77,7 +115,70 @@ class Account::ProviderImportAdapter
|
||||
entry.transaction.extra = existing.deep_merge(incoming)
|
||||
entry.transaction.save!
|
||||
end
|
||||
|
||||
# Set investment activity label if provided and not already set
|
||||
if investment_activity_label.present? && entry.entryable.is_a?(Transaction)
|
||||
if entry.transaction.investment_activity_label.blank?
|
||||
entry.transaction.assign_attributes(investment_activity_label: investment_activity_label)
|
||||
end
|
||||
end
|
||||
|
||||
entry.save!
|
||||
entry.transaction.save! if entry.transaction.changed?
|
||||
|
||||
# AFTER save: For NEW posted transactions, check for fuzzy matches to SUGGEST (not auto-claim)
|
||||
# This handles tip adjustments where auto-matching is too risky
|
||||
if is_new_posted
|
||||
# PRIORITY 1: Try medium-confidence fuzzy match (≤30% amount difference)
|
||||
fuzzy_suggestion = find_pending_transaction_fuzzy(
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
source: source,
|
||||
merchant_id: merchant&.id,
|
||||
name: name
|
||||
)
|
||||
if fuzzy_suggestion
|
||||
# Store suggestion on the PENDING entry for user to review
|
||||
begin
|
||||
store_duplicate_suggestion(
|
||||
pending_entry: fuzzy_suggestion,
|
||||
posted_entry: entry,
|
||||
reason: "fuzzy_amount_match",
|
||||
posted_amount: amount,
|
||||
confidence: "medium"
|
||||
)
|
||||
Rails.logger.info("Suggested potential duplicate (medium confidence): pending entry #{fuzzy_suggestion.id} (#{fuzzy_suggestion.name}, #{fuzzy_suggestion.amount}) may match posted #{entry.name} (#{amount})")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.warn("Failed to store duplicate suggestion for entry #{fuzzy_suggestion.id}: #{e.message}")
|
||||
end
|
||||
else
|
||||
# PRIORITY 2: Try low-confidence match (>30% to 100% difference - big tips)
|
||||
low_confidence_suggestion = find_pending_transaction_low_confidence(
|
||||
date: date,
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
source: source,
|
||||
merchant_id: merchant&.id,
|
||||
name: name
|
||||
)
|
||||
if low_confidence_suggestion
|
||||
begin
|
||||
store_duplicate_suggestion(
|
||||
pending_entry: low_confidence_suggestion,
|
||||
posted_entry: entry,
|
||||
reason: "low_confidence_match",
|
||||
posted_amount: amount,
|
||||
confidence: "low"
|
||||
)
|
||||
Rails.logger.info("Suggested potential duplicate (low confidence): pending entry #{low_confidence_suggestion.id} (#{low_confidence_suggestion.name}, #{low_confidence_suggestion.amount}) may match posted #{entry.name} (#{amount})")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.warn("Failed to store duplicate suggestion for entry #{low_confidence_suggestion.id}: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
entry
|
||||
end
|
||||
end
|
||||
@@ -222,17 +323,32 @@ class Account::ProviderImportAdapter
|
||||
end
|
||||
end
|
||||
|
||||
holding.assign_attributes(
|
||||
# Reconcile cost_basis to respect priority hierarchy
|
||||
reconciled = Holding::CostBasisReconciler.reconcile(
|
||||
existing_holding: holding.persisted? ? holding : nil,
|
||||
incoming_cost_basis: cost_basis,
|
||||
incoming_source: "provider"
|
||||
)
|
||||
|
||||
# Build base attributes
|
||||
attributes = {
|
||||
security: security,
|
||||
date: date,
|
||||
currency: currency,
|
||||
qty: quantity,
|
||||
price: price,
|
||||
amount: amount,
|
||||
cost_basis: cost_basis,
|
||||
account_provider_id: account_provider_id,
|
||||
external_id: external_id
|
||||
)
|
||||
}
|
||||
|
||||
# Only update cost_basis if reconciliation says to
|
||||
if reconciled[:should_update]
|
||||
attributes[:cost_basis] = reconciled[:cost_basis]
|
||||
attributes[:cost_basis_source] = reconciled[:cost_basis_source]
|
||||
end
|
||||
|
||||
holding.assign_attributes(attributes)
|
||||
|
||||
begin
|
||||
Holding.transaction(requires_new: true) do
|
||||
@@ -262,11 +378,22 @@ class Account::ProviderImportAdapter
|
||||
updates = {
|
||||
qty: quantity,
|
||||
price: price,
|
||||
amount: amount,
|
||||
cost_basis: cost_basis
|
||||
amount: amount
|
||||
}
|
||||
|
||||
# Adopt the row to this provider if it’s currently unowned
|
||||
# Reconcile cost_basis to respect priority hierarchy
|
||||
collision_reconciled = Holding::CostBasisReconciler.reconcile(
|
||||
existing_holding: existing,
|
||||
incoming_cost_basis: cost_basis,
|
||||
incoming_source: "provider"
|
||||
)
|
||||
|
||||
if collision_reconciled[:should_update]
|
||||
updates[:cost_basis] = collision_reconciled[:cost_basis]
|
||||
updates[:cost_basis_source] = collision_reconciled[:cost_basis_source]
|
||||
end
|
||||
|
||||
# Adopt the row to this provider if it's currently unowned
|
||||
if account_provider_id.present? && existing.account_provider_id.nil?
|
||||
updates[:account_provider_id] = account_provider_id
|
||||
end
|
||||
@@ -444,4 +571,196 @@ class Account::ProviderImportAdapter
|
||||
|
||||
query.order(created_at: :asc).first
|
||||
end
|
||||
|
||||
# Finds a pending transaction that likely matches a newly posted transaction
|
||||
# Used to reconcile pending→posted when SimpleFIN gives different IDs for the same transaction
|
||||
#
|
||||
# @param date [Date, String] Posted transaction date
|
||||
# @param amount [BigDecimal, Numeric] Transaction amount (must match exactly)
|
||||
# @param currency [String] Currency code
|
||||
# @param source [String] Provider name (e.g., "simplefin")
|
||||
# @param date_window [Integer] Days to search around the posted date (default: 8)
|
||||
# @return [Entry, nil] The pending entry or nil if not found
|
||||
def find_pending_transaction(date:, amount:, currency:, source:, date_window: 8)
|
||||
date = Date.parse(date.to_s) unless date.is_a?(Date)
|
||||
|
||||
# Look for entries that:
|
||||
# 1. Same account (implicit via account.entries)
|
||||
# 2. Same source (simplefin)
|
||||
# 3. Same amount (exact match - this is the strongest signal)
|
||||
# 4. Same currency
|
||||
# 5. Date within window (pending can post days later)
|
||||
# 6. Is a Transaction (not Trade or Valuation)
|
||||
# 7. Has pending=true in transaction.extra["simplefin"]["pending"] or extra["plaid"]["pending"]
|
||||
candidates = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(source: source)
|
||||
.where(amount: amount)
|
||||
.where(currency: currency)
|
||||
.where(date: (date - date_window.days)..date) # Pending must be ON or BEFORE posted date
|
||||
.where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
SQL
|
||||
.order(date: :desc) # Prefer most recent pending transaction
|
||||
|
||||
candidates.first
|
||||
end
|
||||
|
||||
# Finds a pending transaction using fuzzy amount matching for tip adjustments
|
||||
# Used when exact amount matching fails - handles restaurant tips, adjusted authorizations, etc.
|
||||
#
|
||||
# IMPORTANT: Only returns a match if there's exactly ONE candidate to avoid false positives
|
||||
# with recurring merchant transactions (e.g., gas stations, coffee shops).
|
||||
#
|
||||
# @param date [Date, String] Posted transaction date
|
||||
# @param amount [BigDecimal, Numeric] Posted transaction amount (typically higher due to tip)
|
||||
# @param currency [String] Currency code
|
||||
# @param source [String] Provider name (e.g., "simplefin")
|
||||
# @param merchant_id [Integer, nil] Merchant ID for more accurate matching
|
||||
# @param name [String, nil] Transaction name for fuzzy name matching
|
||||
# @param date_window [Integer] Days to search backward from posted date (default: 3 for fuzzy)
|
||||
# @param amount_tolerance [Float] Maximum percentage difference allowed (default: 0.30 = 30%)
|
||||
# @return [Entry, nil] The pending entry or nil if not found/ambiguous
|
||||
def find_pending_transaction_fuzzy(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3, amount_tolerance: 0.30)
|
||||
date = Date.parse(date.to_s) unless date.is_a?(Date)
|
||||
amount = BigDecimal(amount.to_s)
|
||||
|
||||
# Calculate amount bounds using ABS to handle both positive and negative amounts
|
||||
# Posted amount should be >= pending (tips add, not subtract)
|
||||
# Allow posted to be up to 30% higher than pending (covers typical tips)
|
||||
abs_amount = amount.abs
|
||||
min_pending_abs = abs_amount / (1 + amount_tolerance) # If posted is 100, pending could be as low as ~77
|
||||
max_pending_abs = abs_amount # Pending should not be higher than posted
|
||||
|
||||
# Build base query for pending transactions
|
||||
# CRITICAL: Pending must be ON or BEFORE the posted date (authorization happens first)
|
||||
# Use tighter date window (3 days) - tips post quickly, not a week later
|
||||
# Use ABS() for amount comparison to handle negative amounts correctly
|
||||
candidates = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(source: source)
|
||||
.where(currency: currency)
|
||||
.where(date: (date - date_window.days)..date) # Pending ON or BEFORE posted
|
||||
.where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs)
|
||||
.where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
SQL
|
||||
|
||||
# If merchant_id is provided, prioritize matching by merchant
|
||||
if merchant_id.present?
|
||||
merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a
|
||||
# Only match if exactly ONE candidate to avoid false positives
|
||||
return merchant_matches.first if merchant_matches.size == 1
|
||||
if merchant_matches.size > 1
|
||||
Rails.logger.info("Skipping fuzzy pending match: #{merchant_matches.size} ambiguous merchant candidates for amount=#{amount} date=#{date}")
|
||||
end
|
||||
end
|
||||
|
||||
# If name is provided, try fuzzy name matching as fallback
|
||||
if name.present?
|
||||
# Extract first few significant words for comparison
|
||||
name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
||||
if name_words.present?
|
||||
name_matches = candidates.select do |c|
|
||||
c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
||||
name_words == c_name_words
|
||||
end
|
||||
# Only match if exactly ONE candidate to avoid false positives
|
||||
return name_matches.first if name_matches.size == 1
|
||||
if name_matches.size > 1
|
||||
Rails.logger.info("Skipping fuzzy pending match: #{name_matches.size} ambiguous name candidates for '#{name_words}' amount=#{amount} date=#{date}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# No merchant or name match, return nil (too risky to match on amount alone)
|
||||
# This prevents false positives when multiple pending transactions exist
|
||||
nil
|
||||
end
|
||||
|
||||
# Finds a pending transaction with low confidence (>30% to 100% amount difference)
|
||||
# Used for large tip scenarios where normal fuzzy matching would miss
|
||||
# Creates a "review recommended" suggestion rather than "possible duplicate"
|
||||
#
|
||||
# @param date [Date, String] Posted transaction date
|
||||
# @param amount [BigDecimal, Numeric] Posted transaction amount
|
||||
# @param currency [String] Currency code
|
||||
# @param source [String] Provider name
|
||||
# @param merchant_id [Integer, nil] Merchant ID for matching
|
||||
# @param name [String, nil] Transaction name for matching
|
||||
# @param date_window [Integer] Days to search backward (default: 3)
|
||||
# @return [Entry, nil] The pending entry or nil if not found/ambiguous
|
||||
def find_pending_transaction_low_confidence(date:, amount:, currency:, source:, merchant_id: nil, name: nil, date_window: 3)
|
||||
date = Date.parse(date.to_s) unless date.is_a?(Date)
|
||||
amount = BigDecimal(amount.to_s)
|
||||
|
||||
# Allow up to 100% difference (e.g., $50 pending → $100 posted with huge tip)
|
||||
# This is low confidence - requires strong name/merchant match
|
||||
# Use ABS to handle both positive and negative amounts correctly
|
||||
abs_amount = amount.abs
|
||||
min_pending_abs = abs_amount / 2.0 # Posted could be up to 2x pending
|
||||
max_pending_abs = abs_amount * 0.77 # Pending must be at least 30% less (to not overlap with fuzzy)
|
||||
|
||||
# Build base query for pending transactions
|
||||
# Use ABS() for amount comparison to handle negative amounts correctly
|
||||
candidates = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(source: source)
|
||||
.where(currency: currency)
|
||||
.where(date: (date - date_window.days)..date)
|
||||
.where("ABS(entries.amount) BETWEEN ? AND ?", min_pending_abs, max_pending_abs)
|
||||
.where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
SQL
|
||||
|
||||
# For low confidence, require BOTH merchant AND name match (stronger signal needed)
|
||||
if merchant_id.present? && name.present?
|
||||
name_words = name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
||||
return nil if name_words.blank?
|
||||
|
||||
merchant_matches = candidates.where("transactions.merchant_id = ?", merchant_id).to_a
|
||||
name_matches = merchant_matches.select do |c|
|
||||
c_name_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
||||
name_words == c_name_words
|
||||
end
|
||||
|
||||
# Only match if exactly ONE candidate
|
||||
return name_matches.first if name_matches.size == 1
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Stores a duplicate suggestion on a pending entry for user review
|
||||
# The suggestion is stored in the pending transaction's extra field
|
||||
#
|
||||
# @param pending_entry [Entry] The pending entry that may be a duplicate
|
||||
# @param posted_entry [Entry] The posted entry it may match
|
||||
# @param reason [String] Why this was flagged (e.g., "fuzzy_amount_match", "low_confidence_match")
|
||||
# @param posted_amount [BigDecimal] The posted transaction amount
|
||||
# @param confidence [String] Confidence level: "medium" (≤30% diff) or "low" (>30% diff)
|
||||
def store_duplicate_suggestion(pending_entry:, posted_entry:, reason:, posted_amount:, confidence: "medium")
|
||||
return unless pending_entry&.entryable.is_a?(Transaction)
|
||||
|
||||
pending_transaction = pending_entry.entryable
|
||||
existing_extra = pending_transaction.extra || {}
|
||||
|
||||
# Don't overwrite if already has a suggestion (keep first one found)
|
||||
return if existing_extra["potential_posted_match"].present?
|
||||
|
||||
pending_transaction.update!(
|
||||
extra: existing_extra.merge(
|
||||
"potential_posted_match" => {
|
||||
"entry_id" => posted_entry.id,
|
||||
"reason" => reason,
|
||||
"posted_amount" => posted_amount.to_s,
|
||||
"confidence" => confidence,
|
||||
"detected_at" => Date.current.to_s
|
||||
}
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -105,8 +105,8 @@ class Assistant::Function::GetHoldings < Assistant::Function
|
||||
amount: holding.amount.to_f,
|
||||
formatted_amount: holding.amount_money.format,
|
||||
weight: holding.weight&.round(2),
|
||||
average_cost: holding.avg_cost.to_f,
|
||||
formatted_average_cost: holding.avg_cost.format,
|
||||
average_cost: holding.avg_cost&.to_f,
|
||||
formatted_average_cost: holding.avg_cost&.format,
|
||||
account: holding.account.name,
|
||||
date: holding.date
|
||||
}
|
||||
|
||||
@@ -12,11 +12,11 @@ class CoinstatsItem::Syncer
|
||||
# @param sync [Sync] Sync record for status tracking
|
||||
def perform_sync(sync)
|
||||
# Phase 1: Import data from CoinStats API
|
||||
sync.update!(status_text: "Importing wallets from CoinStats...") if sync.respond_to?(:status_text)
|
||||
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.importing_wallets")) if sync.respond_to?(:status_text)
|
||||
coinstats_item.import_latest_coinstats_data
|
||||
|
||||
# Phase 2: Check account setup status and collect sync statistics
|
||||
sync.update!(status_text: "Checking wallet configuration...") if sync.respond_to?(:status_text)
|
||||
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.checking_configuration")) if sync.respond_to?(:status_text)
|
||||
total_accounts = coinstats_item.coinstats_accounts.count
|
||||
|
||||
linked_accounts = coinstats_item.coinstats_accounts.joins(:account_provider).joins(:account).merge(Account.visible)
|
||||
@@ -30,18 +30,18 @@ class CoinstatsItem::Syncer
|
||||
|
||||
if unlinked_accounts.any?
|
||||
coinstats_item.update!(pending_account_setup: true)
|
||||
sync.update!(status_text: "#{unlinked_accounts.count} wallets need setup...") if sync.respond_to?(:status_text)
|
||||
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.wallets_need_setup", count: unlinked_accounts.count)) if sync.respond_to?(:status_text)
|
||||
else
|
||||
coinstats_item.update!(pending_account_setup: false)
|
||||
end
|
||||
|
||||
# Phase 3: Process holdings for linked accounts only
|
||||
if linked_accounts.any?
|
||||
sync.update!(status_text: "Processing holdings...") if sync.respond_to?(:status_text)
|
||||
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.processing_holdings")) if sync.respond_to?(:status_text)
|
||||
coinstats_item.process_accounts
|
||||
|
||||
# Phase 4: Schedule balance calculations for linked accounts
|
||||
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
|
||||
sync.update!(status_text: I18n.t("models.coinstats_item.syncer.calculating_balances")) if sync.respond_to?(:status_text)
|
||||
coinstats_item.schedule_account_syncs(
|
||||
parent_sync: sync,
|
||||
window_start_date: sync.window_start_date,
|
||||
|
||||
@@ -35,6 +35,188 @@ class Entry < ApplicationRecord
|
||||
)
|
||||
}
|
||||
|
||||
# Pending transaction scopes - check Transaction.extra for provider pending flags
|
||||
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
|
||||
scope :pending, -> {
|
||||
joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
SQL
|
||||
}
|
||||
|
||||
scope :excluding_pending, -> {
|
||||
# For non-Transaction entries (Trade, Valuation), always include
|
||||
# For Transaction entries, exclude if pending flag is true
|
||||
where(<<~SQL.squish)
|
||||
entries.entryable_type != 'Transaction'
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM transactions t
|
||||
WHERE t.id = entries.entryable_id
|
||||
AND (
|
||||
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
)
|
||||
)
|
||||
SQL
|
||||
}
|
||||
|
||||
# Find stale pending transactions (pending for more than X days with no matching posted version)
|
||||
scope :stale_pending, ->(days: 8) {
|
||||
pending.where("entries.date < ?", days.days.ago.to_date)
|
||||
}
|
||||
|
||||
# Auto-exclude stale pending transactions for an account
|
||||
# Called during sync to clean up pending transactions that never posted
|
||||
# @param account [Account] The account to clean up
|
||||
# @param days [Integer] Number of days after which pending is considered stale (default: 8)
|
||||
# @return [Integer] Number of entries excluded
|
||||
def self.auto_exclude_stale_pending(account:, days: 8)
|
||||
stale_entries = account.entries.stale_pending(days: days).where(excluded: false)
|
||||
count = stale_entries.count
|
||||
|
||||
if count > 0
|
||||
stale_entries.update_all(excluded: true, updated_at: Time.current)
|
||||
Rails.logger.info("Auto-excluded #{count} stale pending transaction(s) for account #{account.id} (#{account.name})")
|
||||
end
|
||||
|
||||
count
|
||||
end
|
||||
|
||||
# Retroactively reconcile pending transactions that have a matching posted version
|
||||
# This handles duplicates created before reconciliation code was deployed
|
||||
#
|
||||
# @param account [Account, nil] Specific account to clean up, or nil for all accounts
|
||||
# @param dry_run [Boolean] If true, only report what would be done without making changes
|
||||
# @param date_window [Integer] Days to search forward for posted matches (default: 8)
|
||||
# @param amount_tolerance [Float] Percentage difference allowed for fuzzy matching (default: 0.25)
|
||||
# @return [Hash] Stats about what was reconciled
|
||||
def self.reconcile_pending_duplicates(account: nil, dry_run: false, date_window: 8, amount_tolerance: 0.25)
|
||||
stats = { checked: 0, reconciled: 0, details: [] }
|
||||
|
||||
# Get pending entries to check
|
||||
scope = Entry.pending.where(excluded: false)
|
||||
scope = scope.where(account: account) if account
|
||||
|
||||
scope.includes(:account, :entryable).find_each do |pending_entry|
|
||||
stats[:checked] += 1
|
||||
acct = pending_entry.account
|
||||
|
||||
# PRIORITY 1: Look for posted transaction with EXACT amount match
|
||||
# CRITICAL: Only search forward in time - posted date must be >= pending date
|
||||
exact_candidates = acct.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where.not(id: pending_entry.id)
|
||||
.where(currency: pending_entry.currency)
|
||||
.where(amount: pending_entry.amount)
|
||||
.where(date: pending_entry.date..(pending_entry.date + date_window.days)) # Posted must be ON or AFTER pending date
|
||||
.where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
|
||||
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
|
||||
SQL
|
||||
.limit(2) # Only need to know if 0, 1, or 2+ candidates
|
||||
.to_a # Load limited records to avoid COUNT(*) on .size
|
||||
|
||||
# Handle exact match - auto-exclude only if exactly ONE candidate (high confidence)
|
||||
# Multiple candidates = ambiguous = skip to avoid excluding wrong entry
|
||||
if exact_candidates.size == 1
|
||||
posted_match = exact_candidates.first
|
||||
detail = {
|
||||
pending_id: pending_entry.id,
|
||||
pending_name: pending_entry.name,
|
||||
pending_amount: pending_entry.amount.to_f,
|
||||
pending_date: pending_entry.date,
|
||||
posted_id: posted_match.id,
|
||||
posted_name: posted_match.name,
|
||||
posted_amount: posted_match.amount.to_f,
|
||||
posted_date: posted_match.date,
|
||||
account: acct.name,
|
||||
match_type: "exact"
|
||||
}
|
||||
stats[:details] << detail
|
||||
stats[:reconciled] += 1
|
||||
|
||||
unless dry_run
|
||||
pending_entry.update!(excluded: true)
|
||||
Rails.logger.info("Reconciled pending→posted duplicate: excluded entry #{pending_entry.id} (#{pending_entry.name}) matched to #{posted_match.id}")
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
# PRIORITY 2: If no exact match, try fuzzy amount match for tip adjustments
|
||||
# Store as SUGGESTION instead of auto-excluding (medium confidence)
|
||||
pending_amount = pending_entry.amount.abs
|
||||
min_amount = pending_amount
|
||||
max_amount = pending_amount * (1 + amount_tolerance)
|
||||
|
||||
fuzzy_date_window = 3
|
||||
candidates = acct.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where.not(id: pending_entry.id)
|
||||
.where(currency: pending_entry.currency)
|
||||
.where(date: pending_entry.date..(pending_entry.date + fuzzy_date_window.days)) # Posted ON or AFTER pending
|
||||
.where("ABS(entries.amount) BETWEEN ? AND ?", min_amount, max_amount)
|
||||
.where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS NOT TRUE
|
||||
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS NOT TRUE
|
||||
SQL
|
||||
|
||||
# Match by name similarity (first 3 words)
|
||||
name_words = pending_entry.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
||||
if name_words.present?
|
||||
matching_candidates = candidates.select do |c|
|
||||
c_words = c.name.downcase.gsub(/[^a-z0-9\s]/, "").split.first(3).join(" ")
|
||||
name_words == c_words
|
||||
end
|
||||
|
||||
# Only suggest if there's exactly ONE matching candidate
|
||||
# Multiple matches = ambiguous (e.g., recurring gas station visits) = skip
|
||||
if matching_candidates.size == 1
|
||||
fuzzy_match = matching_candidates.first
|
||||
|
||||
detail = {
|
||||
pending_id: pending_entry.id,
|
||||
pending_name: pending_entry.name,
|
||||
pending_amount: pending_entry.amount.to_f,
|
||||
pending_date: pending_entry.date,
|
||||
posted_id: fuzzy_match.id,
|
||||
posted_name: fuzzy_match.name,
|
||||
posted_amount: fuzzy_match.amount.to_f,
|
||||
posted_date: fuzzy_match.date,
|
||||
account: acct.name,
|
||||
match_type: "fuzzy_suggestion"
|
||||
}
|
||||
stats[:details] << detail
|
||||
|
||||
unless dry_run
|
||||
# Store suggestion on the pending entry instead of auto-excluding
|
||||
pending_transaction = pending_entry.entryable
|
||||
if pending_transaction.is_a?(Transaction)
|
||||
existing_extra = pending_transaction.extra || {}
|
||||
unless existing_extra["potential_posted_match"].present?
|
||||
pending_transaction.update!(
|
||||
extra: existing_extra.merge(
|
||||
"potential_posted_match" => {
|
||||
"entry_id" => fuzzy_match.id,
|
||||
"reason" => "fuzzy_amount_match",
|
||||
"posted_amount" => fuzzy_match.amount.to_s,
|
||||
"detected_at" => Date.current.to_s
|
||||
}
|
||||
)
|
||||
)
|
||||
Rails.logger.info("Stored duplicate suggestion for entry #{pending_entry.id} (#{pending_entry.name}) → #{fuzzy_match.id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
elsif matching_candidates.size > 1
|
||||
Rails.logger.info("Skipping fuzzy reconciliation for #{pending_entry.id} (#{pending_entry.name}): #{matching_candidates.size} ambiguous candidates")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
stats
|
||||
end
|
||||
|
||||
def classification
|
||||
amount.negative? ? "income" : "expense"
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class EntrySearch
|
||||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, :string
|
||||
attribute :status, array: true
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
@@ -16,7 +17,7 @@ class EntrySearch
|
||||
return scope if search.blank?
|
||||
|
||||
query = scope
|
||||
query = query.where("entries.name ILIKE :search",
|
||||
query = query.where("entries.name ILIKE :search OR entries.notes ILIKE :search",
|
||||
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
|
||||
)
|
||||
query
|
||||
@@ -56,6 +57,44 @@ class EntrySearch
|
||||
query = query.where(accounts: { id: account_ids }) if account_ids.present?
|
||||
query
|
||||
end
|
||||
|
||||
def apply_status_filter(scope, statuses)
|
||||
return scope unless statuses.present?
|
||||
return scope if statuses.uniq.sort == %w[confirmed pending] # Both selected = no filter
|
||||
|
||||
pending_condition = <<~SQL.squish
|
||||
entries.entryable_type = 'Transaction'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM transactions t
|
||||
WHERE t.id = entries.entryable_id
|
||||
AND (
|
||||
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
)
|
||||
)
|
||||
SQL
|
||||
|
||||
confirmed_condition = <<~SQL.squish
|
||||
entries.entryable_type != 'Transaction'
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM transactions t
|
||||
WHERE t.id = entries.entryable_id
|
||||
AND (
|
||||
(t.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (t.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
)
|
||||
)
|
||||
SQL
|
||||
|
||||
case statuses.sort
|
||||
when [ "pending" ]
|
||||
scope.where(pending_condition)
|
||||
when [ "confirmed" ]
|
||||
scope.where(confirmed_condition)
|
||||
else
|
||||
scope
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_query(scope)
|
||||
@@ -64,6 +103,7 @@ class EntrySearch
|
||||
query = self.class.apply_date_filters(query, start_date, end_date)
|
||||
query = self.class.apply_amount_filter(query, amount, amount_operator)
|
||||
query = self.class.apply_accounts_filter(query, accounts, account_ids)
|
||||
query = self.class.apply_status_filter(query, status)
|
||||
query
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,16 @@ class Holding < ApplicationRecord
|
||||
|
||||
monetize :amount
|
||||
|
||||
# Cost basis source priority (higher = takes precedence)
|
||||
COST_BASIS_SOURCE_PRIORITY = {
|
||||
nil => 0,
|
||||
"provider" => 1,
|
||||
"calculated" => 2,
|
||||
"manual" => 3
|
||||
}.freeze
|
||||
|
||||
COST_BASIS_SOURCES = %w[manual calculated provider].freeze
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :security
|
||||
belongs_to :account_provider, optional: true
|
||||
@@ -10,9 +20,12 @@ class Holding < ApplicationRecord
|
||||
validates :qty, :currency, :date, :price, :amount, presence: true
|
||||
validates :qty, :price, :amount, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :external_id, uniqueness: { scope: :account_id }, allow_blank: true
|
||||
validates :cost_basis_source, inclusion: { in: COST_BASIS_SOURCES }, allow_nil: true
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
scope :for, ->(security) { where(security_id: security).order(:date) }
|
||||
scope :with_locked_cost_basis, -> { where(cost_basis_locked: true) }
|
||||
scope :with_unlocked_cost_basis, -> { where(cost_basis_locked: false) }
|
||||
|
||||
delegate :ticker, to: :security
|
||||
|
||||
@@ -27,12 +40,16 @@ class Holding < ApplicationRecord
|
||||
account.balance.zero? ? 1 : amount / account.balance * 100
|
||||
end
|
||||
|
||||
# Basic approximation of cost-basis
|
||||
# Returns average cost per share, or nil if unknown.
|
||||
#
|
||||
# Uses pre-computed cost_basis if available (set during materialization),
|
||||
# otherwise falls back to calculating from trades
|
||||
# otherwise falls back to calculating from trades. Returns nil when cost
|
||||
# basis cannot be determined (no trades and no provider cost_basis).
|
||||
def avg_cost
|
||||
# Use stored cost_basis if available (eliminates N+1 queries)
|
||||
return Money.new(cost_basis, currency) if cost_basis.present?
|
||||
# Use stored cost_basis if available and positive (eliminates N+1 queries)
|
||||
# Note: cost_basis of 0 is treated as "unknown" since providers sometimes
|
||||
# return 0 when they don't have the data
|
||||
return Money.new(cost_basis, currency) if cost_basis.present? && cost_basis.positive?
|
||||
|
||||
# Fallback to calculation for holdings without pre-computed cost_basis
|
||||
calculate_avg_cost
|
||||
@@ -72,9 +89,57 @@ class Holding < ApplicationRecord
|
||||
account.sync_later
|
||||
end
|
||||
|
||||
# Returns the priority level for the current source (higher = better)
|
||||
def cost_basis_source_priority
|
||||
COST_BASIS_SOURCE_PRIORITY[cost_basis_source] || 0
|
||||
end
|
||||
|
||||
# Check if this holding's cost_basis can be overwritten by the given source
|
||||
def cost_basis_replaceable_by?(new_source)
|
||||
return false if cost_basis_locked?
|
||||
|
||||
new_priority = COST_BASIS_SOURCE_PRIORITY[new_source] || 0
|
||||
|
||||
# Special case: when user unlocks a manual cost_basis, they're opting into
|
||||
# recalculation. Allow only "calculated" source to replace it (from trades).
|
||||
# This is the whole point of the unlock action.
|
||||
if cost_basis_source == "manual"
|
||||
return new_source == "calculated"
|
||||
end
|
||||
|
||||
new_priority > cost_basis_source_priority
|
||||
end
|
||||
|
||||
# Set cost_basis from user input (locks the value)
|
||||
def set_manual_cost_basis!(value)
|
||||
update!(
|
||||
cost_basis: value,
|
||||
cost_basis_source: "manual",
|
||||
cost_basis_locked: true
|
||||
)
|
||||
end
|
||||
|
||||
# Unlock cost_basis to allow provider/calculated updates
|
||||
def unlock_cost_basis!
|
||||
update!(cost_basis_locked: false)
|
||||
end
|
||||
|
||||
# Check if cost_basis is known (has a source and positive value)
|
||||
def cost_basis_known?
|
||||
cost_basis.present? && cost_basis.positive? && cost_basis_source.present?
|
||||
end
|
||||
|
||||
# Human-readable source label for UI display
|
||||
def cost_basis_source_label
|
||||
return nil unless cost_basis_source.present?
|
||||
|
||||
I18n.t("holdings.cost_basis_sources.#{cost_basis_source}")
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_trend
|
||||
return nil unless amount_money
|
||||
return nil unless avg_cost # Can't calculate trend without cost basis
|
||||
|
||||
start_amount = qty * avg_cost
|
||||
|
||||
@@ -83,6 +148,8 @@ class Holding < ApplicationRecord
|
||||
previous: start_amount
|
||||
end
|
||||
|
||||
# Calculates weighted average cost from buy trades.
|
||||
# Returns nil if no trades exist (cost basis is unknown).
|
||||
def calculate_avg_cost
|
||||
trades = account.trades
|
||||
.with_entry
|
||||
@@ -101,13 +168,10 @@ class Holding < ApplicationRecord
|
||||
Arel.sql("SUM(trades.qty)")
|
||||
)
|
||||
|
||||
weighted_avg =
|
||||
if total_qty && total_qty > 0
|
||||
total_cost / total_qty
|
||||
else
|
||||
price
|
||||
end
|
||||
# Return nil when no trades exist - cost basis is genuinely unknown
|
||||
# Previously this fell back to current market price, which was misleading
|
||||
return nil unless total_qty && total_qty > 0
|
||||
|
||||
Money.new(weighted_avg || price, currency)
|
||||
Money.new(total_cost / total_qty, currency)
|
||||
end
|
||||
end
|
||||
|
||||
58
app/models/holding/cost_basis_reconciler.rb
Normal file
58
app/models/holding/cost_basis_reconciler.rb
Normal file
@@ -0,0 +1,58 @@
|
||||
# Determines the appropriate cost_basis value and source when updating a holding.
|
||||
#
|
||||
# Used by both Materializer (for trade-derived calculations) and
|
||||
# ProviderImportAdapter (for provider-supplied values) to ensure consistent
|
||||
# reconciliation logic across all data sources.
|
||||
#
|
||||
# Priority hierarchy: manual > calculated > provider > unknown
|
||||
#
|
||||
class Holding::CostBasisReconciler
|
||||
# Determines the appropriate cost_basis value and source for a holding update
|
||||
#
|
||||
# @param existing_holding [Holding, nil] The existing holding record (nil for new)
|
||||
# @param incoming_cost_basis [BigDecimal, nil] The incoming cost_basis value
|
||||
# @param incoming_source [String] The source of incoming data ('calculated', 'provider')
|
||||
# @return [Hash] { cost_basis: value, cost_basis_source: source, should_update: boolean }
|
||||
def self.reconcile(existing_holding:, incoming_cost_basis:, incoming_source:)
|
||||
# Treat zero cost_basis from provider as unknown
|
||||
if incoming_source == "provider" && (incoming_cost_basis.nil? || incoming_cost_basis.zero?)
|
||||
incoming_cost_basis = nil
|
||||
end
|
||||
|
||||
# New holding - use whatever we have
|
||||
if existing_holding.nil?
|
||||
return {
|
||||
cost_basis: incoming_cost_basis,
|
||||
cost_basis_source: incoming_cost_basis.present? ? incoming_source : nil,
|
||||
should_update: true
|
||||
}
|
||||
end
|
||||
|
||||
# Locked - never overwrite
|
||||
if existing_holding.cost_basis_locked?
|
||||
return {
|
||||
cost_basis: existing_holding.cost_basis,
|
||||
cost_basis_source: existing_holding.cost_basis_source,
|
||||
should_update: false
|
||||
}
|
||||
end
|
||||
|
||||
# Check priority - can the incoming source replace the existing?
|
||||
if existing_holding.cost_basis_replaceable_by?(incoming_source)
|
||||
if incoming_cost_basis.present?
|
||||
return {
|
||||
cost_basis: incoming_cost_basis,
|
||||
cost_basis_source: incoming_source,
|
||||
should_update: true
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Keep existing (equal or lower priority, or incoming is nil)
|
||||
{
|
||||
cost_basis: existing_holding.cost_basis,
|
||||
cost_basis_source: existing_holding.cost_basis_source,
|
||||
should_update: false
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -27,14 +27,75 @@ class Holding::Materializer
|
||||
end
|
||||
|
||||
def persist_holdings
|
||||
return if @holdings.empty?
|
||||
|
||||
current_time = Time.now
|
||||
|
||||
account.holdings.upsert_all(
|
||||
@holdings.map { |h| h.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis")
|
||||
.merge("account_id" => account.id, "updated_at" => current_time) },
|
||||
unique_by: %i[account_id security_id date currency]
|
||||
)
|
||||
# Load existing holdings to check locked status and source priority
|
||||
existing_holdings_map = load_existing_holdings_map
|
||||
|
||||
# Separate holdings into categories based on cost_basis reconciliation
|
||||
holdings_to_upsert_with_cost = []
|
||||
holdings_to_upsert_without_cost = []
|
||||
|
||||
@holdings.each do |holding|
|
||||
key = holding_key(holding)
|
||||
existing = existing_holdings_map[key]
|
||||
|
||||
reconciled = Holding::CostBasisReconciler.reconcile(
|
||||
existing_holding: existing,
|
||||
incoming_cost_basis: holding.cost_basis,
|
||||
incoming_source: "calculated"
|
||||
)
|
||||
|
||||
base_attrs = holding.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("account_id" => account.id, "updated_at" => current_time)
|
||||
|
||||
if existing&.cost_basis_locked?
|
||||
# For locked holdings, preserve ALL cost_basis fields
|
||||
holdings_to_upsert_without_cost << base_attrs
|
||||
elsif reconciled[:should_update] && reconciled[:cost_basis].present?
|
||||
# Update with new cost_basis and source
|
||||
holdings_to_upsert_with_cost << base_attrs.merge(
|
||||
"cost_basis" => reconciled[:cost_basis],
|
||||
"cost_basis_source" => reconciled[:cost_basis_source]
|
||||
)
|
||||
else
|
||||
# No cost_basis to set, or existing is better - don't touch cost_basis fields
|
||||
holdings_to_upsert_without_cost << base_attrs
|
||||
end
|
||||
end
|
||||
|
||||
# Upsert with cost_basis updates
|
||||
if holdings_to_upsert_with_cost.any?
|
||||
account.holdings.upsert_all(
|
||||
holdings_to_upsert_with_cost,
|
||||
unique_by: %i[account_id security_id date currency]
|
||||
)
|
||||
end
|
||||
|
||||
# Upsert without cost_basis (preserves existing)
|
||||
if holdings_to_upsert_without_cost.any?
|
||||
account.holdings.upsert_all(
|
||||
holdings_to_upsert_without_cost,
|
||||
unique_by: %i[account_id security_id date currency]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def load_existing_holdings_map
|
||||
# Load holdings that might affect reconciliation:
|
||||
# - Locked holdings (must preserve their cost_basis)
|
||||
# - Holdings with a source (need to check priority)
|
||||
account.holdings
|
||||
.where(cost_basis_locked: true)
|
||||
.or(account.holdings.where.not(cost_basis_source: nil))
|
||||
.index_by { |h| holding_key(h) }
|
||||
end
|
||||
|
||||
def holding_key(holding)
|
||||
[ holding.account_id || account.id, holding.security_id, holding.date, holding.currency ]
|
||||
end
|
||||
|
||||
def purge_stale_holdings
|
||||
|
||||
@@ -22,6 +22,7 @@ class Import < ApplicationRecord
|
||||
belongs_to :account, optional: true
|
||||
|
||||
before_validation :set_default_number_format
|
||||
before_validation :ensure_utf8_encoding
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
@@ -302,6 +303,68 @@ class Import < ApplicationRecord
|
||||
self.number_format ||= "1,234.56" # Default to US/UK format
|
||||
end
|
||||
|
||||
# Common encodings to try when UTF-8 detection fails
|
||||
# Windows-1250 is prioritized for Central/Eastern European languages
|
||||
COMMON_ENCODINGS = [ "Windows-1250", "Windows-1252", "ISO-8859-1", "ISO-8859-2" ].freeze
|
||||
|
||||
def ensure_utf8_encoding
|
||||
# Handle nil or empty string first (before checking if changed)
|
||||
return if raw_file_str.nil? || raw_file_str.bytesize == 0
|
||||
|
||||
# Only process if the attribute was changed
|
||||
# Use will_save_change_to_attribute? which is safer for binary data
|
||||
return unless will_save_change_to_raw_file_str?
|
||||
|
||||
# If already valid UTF-8, nothing to do
|
||||
begin
|
||||
if raw_file_str.encoding == Encoding::UTF_8 && raw_file_str.valid_encoding?
|
||||
return
|
||||
end
|
||||
rescue ArgumentError
|
||||
# raw_file_str might have invalid encoding, continue to detection
|
||||
end
|
||||
|
||||
# Detect encoding using rchardet
|
||||
begin
|
||||
require "rchardet"
|
||||
detection = CharDet.detect(raw_file_str)
|
||||
detected_encoding = detection["encoding"]
|
||||
confidence = detection["confidence"]
|
||||
|
||||
# Only convert if we have reasonable confidence in the detection
|
||||
if detected_encoding && confidence > 0.75
|
||||
# Force encoding and convert to UTF-8
|
||||
self.raw_file_str = raw_file_str.force_encoding(detected_encoding).encode("UTF-8", invalid: :replace, undef: :replace)
|
||||
else
|
||||
# Fallback: try common encodings
|
||||
try_common_encodings
|
||||
end
|
||||
rescue LoadError
|
||||
# rchardet not available, fallback to trying common encodings
|
||||
try_common_encodings
|
||||
rescue ArgumentError, Encoding::CompatibilityError => e
|
||||
# Handle encoding errors by falling back to common encodings
|
||||
try_common_encodings
|
||||
end
|
||||
end
|
||||
|
||||
def try_common_encodings
|
||||
COMMON_ENCODINGS.each do |encoding|
|
||||
begin
|
||||
test = raw_file_str.dup.force_encoding(encoding)
|
||||
if test.valid_encoding?
|
||||
self.raw_file_str = test.encode("UTF-8", invalid: :replace, undef: :replace)
|
||||
return
|
||||
end
|
||||
rescue Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
# If nothing worked, force UTF-8 and replace invalid bytes
|
||||
self.raw_file_str = raw_file_str.force_encoding("UTF-8").scrub("?")
|
||||
end
|
||||
|
||||
def account_belongs_to_family
|
||||
return if account.nil?
|
||||
return if account.family_id == family_id
|
||||
|
||||
@@ -12,7 +12,9 @@ class IncomeStatement
|
||||
end
|
||||
|
||||
def totals(transactions_scope: nil, date_range:)
|
||||
transactions_scope ||= family.transactions.visible
|
||||
# Default to excluding pending transactions from budget/analytics calculations
|
||||
# Pending transactions shouldn't affect budget totals until they post
|
||||
transactions_scope ||= family.transactions.visible.excluding_pending
|
||||
|
||||
result = totals_query(transactions_scope: transactions_scope, date_range: date_range)
|
||||
|
||||
@@ -64,7 +66,8 @@ class IncomeStatement
|
||||
end
|
||||
|
||||
def build_period_total(classification:, period:)
|
||||
totals = totals_query(transactions_scope: family.transactions.visible.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
|
||||
# Exclude pending transactions from budget calculations
|
||||
totals = totals_query(transactions_scope: family.transactions.visible.excluding_pending.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
|
||||
classification_total = totals.sum(&:total)
|
||||
|
||||
uncategorized_category = family.categories.uncategorized
|
||||
@@ -127,7 +130,7 @@ class IncomeStatement
|
||||
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
|
||||
|
||||
Rails.cache.fetch([
|
||||
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
|
||||
"income_statement", "totals_query", "v2", family.id, sql_hash, family.entries_cache_version
|
||||
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range).call }
|
||||
end
|
||||
|
||||
|
||||
@@ -47,8 +47,10 @@ class IncomeStatement::CategoryStats
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
GROUP BY c.id, period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -44,8 +44,10 @@ class IncomeStatement::FamilyStats
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE a.family_id = :family_id
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
AND t.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND (t.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (t.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
GROUP BY period, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
)
|
||||
SELECT
|
||||
|
||||
@@ -69,9 +69,9 @@ class IncomeStatement::Totals
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND a.family_id = :family_id
|
||||
AND a.family_id = :family_id
|
||||
AND a.status IN ('draft', 'active')
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
|
||||
SQL
|
||||
@@ -95,9 +95,9 @@ class IncomeStatement::Totals
|
||||
er.from_currency = ae.currency AND
|
||||
er.to_currency = :target_currency
|
||||
)
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
|
||||
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment', 'investment_contribution')
|
||||
AND ae.excluded = false
|
||||
AND a.family_id = :family_id
|
||||
AND a.family_id = :family_id
|
||||
AND a.status IN ('draft', 'active')
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
|
||||
SQL
|
||||
@@ -126,7 +126,7 @@ class IncomeStatement::Totals
|
||||
WHERE a.family_id = :family_id
|
||||
AND a.status IN ('draft', 'active')
|
||||
AND ae.excluded = false
|
||||
AND ae.date BETWEEN :start_date AND :end_date
|
||||
AND ae.date BETWEEN :start_date AND :end_date
|
||||
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END, CASE WHEN t.category_id IS NULL THEN true ELSE false END
|
||||
SQL
|
||||
end
|
||||
|
||||
@@ -133,8 +133,12 @@ class InvestmentStatement
|
||||
holdings = current_holdings.to_a
|
||||
return nil if holdings.empty?
|
||||
|
||||
current = holdings.sum(&:amount)
|
||||
previous = holdings.sum { |h| h.qty * h.avg_cost.amount }
|
||||
# Only include holdings with known cost basis in the calculation
|
||||
holdings_with_cost_basis = holdings.select(&:avg_cost)
|
||||
return nil if holdings_with_cost_basis.empty?
|
||||
|
||||
current = holdings_with_cost_basis.sum(&:amount)
|
||||
previous = holdings_with_cost_basis.sum { |h| h.qty * h.avg_cost.amount }
|
||||
|
||||
Trend.new(current: current, previous: previous)
|
||||
end
|
||||
|
||||
@@ -10,12 +10,79 @@ class OidcIdentity < ApplicationRecord
|
||||
update!(last_authenticated_at: Time.current)
|
||||
end
|
||||
|
||||
# Sync user attributes from IdP on each login
|
||||
# Updates stored identity info and syncs name to user (not email - that's identity)
|
||||
def sync_user_attributes!(auth)
|
||||
# Extract groups from claims (various common claim names)
|
||||
groups = extract_groups(auth)
|
||||
|
||||
# Update stored identity info with latest from IdP
|
||||
update!(info: {
|
||||
email: auth.info&.email,
|
||||
name: auth.info&.name,
|
||||
first_name: auth.info&.first_name,
|
||||
last_name: auth.info&.last_name,
|
||||
groups: groups
|
||||
})
|
||||
|
||||
# Sync name to user if provided (keep existing if IdP doesn't provide)
|
||||
user.update!(
|
||||
first_name: auth.info&.first_name.presence || user.first_name,
|
||||
last_name: auth.info&.last_name.presence || user.last_name
|
||||
)
|
||||
|
||||
# Apply role mapping based on group membership
|
||||
apply_role_mapping!(groups)
|
||||
end
|
||||
|
||||
# Extract groups from various common IdP claim formats
|
||||
def extract_groups(auth)
|
||||
# Try various common group claim locations
|
||||
groups = auth.extra&.raw_info&.groups ||
|
||||
auth.extra&.raw_info&.[]("groups") ||
|
||||
auth.extra&.raw_info&.[]("Group") ||
|
||||
auth.info&.groups ||
|
||||
auth.extra&.raw_info&.[]("http://schemas.microsoft.com/ws/2008/06/identity/claims/groups") ||
|
||||
auth.extra&.raw_info&.[]("cognito:groups") ||
|
||||
[]
|
||||
|
||||
# Normalize to array of strings
|
||||
Array(groups).map(&:to_s)
|
||||
end
|
||||
|
||||
# Apply role mapping based on IdP group membership
|
||||
def apply_role_mapping!(groups)
|
||||
config = provider_config
|
||||
return unless config.present?
|
||||
|
||||
role_mapping = config.dig(:settings, :role_mapping) || config.dig(:settings, "role_mapping")
|
||||
return unless role_mapping.present?
|
||||
|
||||
# Check roles in order of precedence (highest to lowest)
|
||||
%w[super_admin admin member].each do |role|
|
||||
mapped_groups = role_mapping[role] || role_mapping[role.to_sym] || []
|
||||
mapped_groups = Array(mapped_groups)
|
||||
|
||||
# Check if user is in any of the mapped groups
|
||||
if mapped_groups.include?("*") || (mapped_groups & groups).any?
|
||||
# Only update if different to avoid unnecessary writes
|
||||
user.update!(role: role) unless user.role == role
|
||||
Rails.logger.info("[SSO] Applied role mapping: user_id=#{user.id} role=#{role} groups=#{groups}")
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Extract and store relevant info from OmniAuth auth hash
|
||||
def self.create_from_omniauth(auth, user)
|
||||
# Extract issuer from OIDC auth response if available
|
||||
issuer = auth.extra&.raw_info&.iss || auth.extra&.raw_info&.[]("iss")
|
||||
|
||||
create!(
|
||||
user: user,
|
||||
provider: auth.provider,
|
||||
uid: auth.uid,
|
||||
issuer: issuer,
|
||||
info: {
|
||||
email: auth.info&.email,
|
||||
name: auth.info&.name,
|
||||
@@ -25,4 +92,20 @@ class OidcIdentity < ApplicationRecord
|
||||
last_authenticated_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Find the configured provider for this identity
|
||||
def provider_config
|
||||
Rails.configuration.x.auth.sso_providers&.find { |p| p[:name] == provider || p[:id] == provider }
|
||||
end
|
||||
|
||||
# Validate that the stored issuer matches the configured provider's issuer
|
||||
# Returns true if valid, false if mismatch (security concern)
|
||||
def issuer_matches_config?
|
||||
return true if issuer.blank? # Backward compatibility for old records
|
||||
|
||||
config = provider_config
|
||||
return true if config.blank? || config[:issuer].blank? # No config to validate against
|
||||
|
||||
issuer == config[:issuer]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
class PlaidAccount::Investments::TransactionsProcessor
|
||||
SecurityNotFoundError = Class.new(StandardError)
|
||||
|
||||
# Map Plaid investment transaction types to activity labels
|
||||
# All values must be valid Transaction::ACTIVITY_LABELS
|
||||
PLAID_TYPE_TO_LABEL = {
|
||||
"buy" => "Buy",
|
||||
"sell" => "Sell",
|
||||
"cancel" => "Other",
|
||||
"cash" => "Other",
|
||||
"fee" => "Fee",
|
||||
"transfer" => "Transfer",
|
||||
"dividend" => "Dividend",
|
||||
"interest" => "Interest",
|
||||
"contribution" => "Contribution",
|
||||
"withdrawal" => "Withdrawal",
|
||||
"dividend reinvestment" => "Reinvestment",
|
||||
"spin off" => "Other",
|
||||
"split" => "Other"
|
||||
}.freeze
|
||||
|
||||
def initialize(plaid_account, security_resolver:)
|
||||
@plaid_account = plaid_account
|
||||
@security_resolver = security_resolver
|
||||
@@ -68,10 +86,16 @@ class PlaidAccount::Investments::TransactionsProcessor
|
||||
currency: transaction["iso_currency_code"],
|
||||
date: transaction["date"],
|
||||
name: transaction["name"],
|
||||
source: "plaid"
|
||||
source: "plaid",
|
||||
investment_activity_label: label_from_plaid_type(transaction)
|
||||
)
|
||||
end
|
||||
|
||||
def label_from_plaid_type(transaction)
|
||||
plaid_type = transaction["type"]&.downcase
|
||||
PLAID_TYPE_TO_LABEL[plaid_type] || "Other"
|
||||
end
|
||||
|
||||
def transactions
|
||||
plaid_account.raw_investments_payload["transactions"] || []
|
||||
end
|
||||
|
||||
@@ -51,7 +51,20 @@ class PlaidAccount::Transactions::Processor
|
||||
modified = plaid_account.raw_transactions_payload["modified"] || []
|
||||
added = plaid_account.raw_transactions_payload["added"] || []
|
||||
|
||||
modified + added
|
||||
transactions = modified + added
|
||||
|
||||
# Filter out pending transactions based on env var or Setting
|
||||
# Priority: env var > Setting (allows runtime changes via UI)
|
||||
include_pending = if ENV["PLAID_INCLUDE_PENDING"].present?
|
||||
Rails.configuration.x.plaid.include_pending
|
||||
else
|
||||
Setting.syncs_include_pending
|
||||
end
|
||||
unless include_pending
|
||||
transactions = transactions.reject { |t| t["pending"] == true }
|
||||
end
|
||||
|
||||
transactions
|
||||
end
|
||||
|
||||
def removed_transactions
|
||||
|
||||
@@ -16,9 +16,11 @@ class PlaidEntry::Processor
|
||||
source: "plaid",
|
||||
category_id: matched_category&.id,
|
||||
merchant: merchant,
|
||||
pending_transaction_id: pending_transaction_id, # Plaid's linking ID for pending→posted
|
||||
extra: {
|
||||
plaid: {
|
||||
pending: plaid_transaction["pending"]
|
||||
pending: plaid_transaction["pending"],
|
||||
pending_transaction_id: pending_transaction_id # Also store for reference
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -55,6 +57,12 @@ class PlaidEntry::Processor
|
||||
plaid_transaction["date"]
|
||||
end
|
||||
|
||||
# Plaid provides this linking ID when a posted transaction matches a pending one
|
||||
# This is the most reliable way to reconcile pending→posted
|
||||
def pending_transaction_id
|
||||
plaid_transaction["pending_transaction_id"]
|
||||
end
|
||||
|
||||
def detailed_category
|
||||
plaid_transaction.dig("personal_finance_category", "detailed")
|
||||
end
|
||||
|
||||
@@ -25,6 +25,9 @@ class Provider::Coinstats < Provider
|
||||
res = self.class.get("#{BASE_URL}/wallet/blockchains", headers: auth_headers)
|
||||
handle_response(res)
|
||||
end
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "CoinStats API: GET /wallet/blockchains failed: #{e.class}: #{e.message}"
|
||||
raise Error, "CoinStats API request failed: #{e.message}"
|
||||
end
|
||||
|
||||
# Returns blockchain options formatted for select dropdowns
|
||||
@@ -75,6 +78,9 @@ class Provider::Coinstats < Provider
|
||||
)
|
||||
handle_response(res)
|
||||
end
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "CoinStats API: GET /wallet/balances failed: #{e.class}: #{e.message}"
|
||||
raise Error, "CoinStats API request failed: #{e.message}"
|
||||
end
|
||||
|
||||
# Extract balance data for a specific wallet from bulk response
|
||||
@@ -114,6 +120,9 @@ class Provider::Coinstats < Provider
|
||||
)
|
||||
handle_response(res)
|
||||
end
|
||||
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
|
||||
Rails.logger.error "CoinStats API: GET /wallet/transactions failed: #{e.class}: #{e.message}"
|
||||
raise Error, "CoinStats API request failed: #{e.message}"
|
||||
end
|
||||
|
||||
# Extract transaction data for a specific wallet from bulk response
|
||||
@@ -162,23 +171,36 @@ class Provider::Coinstats < Provider
|
||||
when 200
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
when 400
|
||||
raise Error, "CoinStats: #{response.code} Bad Request - Invalid parameters or request format #{response.body}"
|
||||
log_api_error(response, "Bad Request")
|
||||
raise Error, "CoinStats: Invalid request parameters"
|
||||
when 401
|
||||
raise Error, "CoinStats: #{response.code} Unauthorized - Invalid or missing API key #{response.body}"
|
||||
log_api_error(response, "Unauthorized")
|
||||
raise Error, "CoinStats: Invalid or missing API key"
|
||||
when 403
|
||||
raise Error, "CoinStats: #{response.code} Forbidden - #{response.body}"
|
||||
log_api_error(response, "Forbidden")
|
||||
raise Error, "CoinStats: Access denied"
|
||||
when 404
|
||||
raise Error, "CoinStats: #{response.code} Not Found - Resource not found #{response.body}"
|
||||
log_api_error(response, "Not Found")
|
||||
raise Error, "CoinStats: Resource not found"
|
||||
when 409
|
||||
raise Error, "CoinStats: #{response.code} Conflict - Resource conflict #{response.body}"
|
||||
log_api_error(response, "Conflict")
|
||||
raise Error, "CoinStats: Resource conflict"
|
||||
when 429
|
||||
raise Error, "CoinStats: #{response.code} Too Many Requests - Rate limit exceeded #{response.body}"
|
||||
log_api_error(response, "Too Many Requests")
|
||||
raise Error, "CoinStats: Rate limit exceeded, try again later"
|
||||
when 500
|
||||
raise Error, "CoinStats: #{response.code} Internal Server Error - Server error #{response.body}"
|
||||
log_api_error(response, "Internal Server Error")
|
||||
raise Error, "CoinStats: Server error, try again later"
|
||||
when 503
|
||||
raise Error, "CoinStats: #{response.code} Service Unavailable - #{response.body}"
|
||||
log_api_error(response, "Service Unavailable")
|
||||
raise Error, "CoinStats: Service temporarily unavailable"
|
||||
else
|
||||
raise Error, "CoinStats: #{response.code} Unexpected Error - #{response.body}"
|
||||
log_api_error(response, "Unexpected Error")
|
||||
raise Error, "CoinStats: An unexpected error occurred"
|
||||
end
|
||||
end
|
||||
|
||||
def log_api_error(response, error_type)
|
||||
Rails.logger.error "CoinStats API: #{response.code} #{error_type} - #{response.body}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -534,7 +534,8 @@ class Provider::Openai::AutoCategorizer
|
||||
# Format transactions in a simpler, more readable way for smaller LLMs
|
||||
def format_transactions_simply
|
||||
transactions.map do |t|
|
||||
"- ID: #{t[:id]}, Amount: #{t[:amount]}, Type: #{t[:classification]}, Description: \"#{t[:description]}\""
|
||||
description = t[:description].presence || t[:merchant].presence || ""
|
||||
"- ID: #{t[:id]}, Amount: #{t[:amount]}, Type: #{t[:classification]}, Description: \"#{description}\""
|
||||
end.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -491,7 +491,9 @@ class Provider::Openai::AutoMerchantDetector
|
||||
# Format transactions in a simpler, more readable way for smaller LLMs
|
||||
def format_transactions_simply
|
||||
transactions.map do |t|
|
||||
"- ID: #{t[:id]}, Description: \"#{t[:name] || t[:description]}\""
|
||||
parts = [ t[:merchant], t[:description] ].compact.reject(&:blank?)
|
||||
combined = parts.join(" - ")
|
||||
"- ID: #{t[:id]}, Description: \"#{combined}\""
|
||||
end.join("\n")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
class Rule::ActionExecutor::SetInvestmentActivityLabel < Rule::ActionExecutor
|
||||
def label
|
||||
"Set investment activity label"
|
||||
end
|
||||
|
||||
def type
|
||||
"select"
|
||||
end
|
||||
|
||||
def options
|
||||
Transaction::ACTIVITY_LABELS.map { |l| [ l, l ] }
|
||||
end
|
||||
|
||||
def execute(transaction_scope, value: nil, ignore_attribute_locks: false, rule_run: nil)
|
||||
return 0 unless Transaction::ACTIVITY_LABELS.include?(value)
|
||||
|
||||
scope = transaction_scope
|
||||
|
||||
unless ignore_attribute_locks
|
||||
scope = scope.enrichable(:investment_activity_label)
|
||||
end
|
||||
|
||||
count_modified_resources(scope) do |txn|
|
||||
txn.enrich_attribute(
|
||||
:investment_activity_label,
|
||||
value,
|
||||
source: "rule"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -20,6 +20,7 @@ class Rule::Registry::TransactionResource < Rule::Registry
|
||||
Rule::ActionExecutor::SetTransactionTags.new(rule),
|
||||
Rule::ActionExecutor::SetTransactionMerchant.new(rule),
|
||||
Rule::ActionExecutor::SetTransactionName.new(rule),
|
||||
Rule::ActionExecutor::SetInvestmentActivityLabel.new(rule),
|
||||
Rule::ActionExecutor::ExcludeTransaction.new(rule)
|
||||
]
|
||||
|
||||
|
||||
@@ -16,6 +16,30 @@ class Setting < RailsSettings::Base
|
||||
field :exchange_rate_provider, type: :string, default: ENV.fetch("EXCHANGE_RATE_PROVIDER", "twelve_data")
|
||||
field :securities_provider, type: :string, default: ENV.fetch("SECURITIES_PROVIDER", "twelve_data")
|
||||
|
||||
# Sync settings - check both provider env vars for default
|
||||
# Only defaults to true if neither provider explicitly disables pending
|
||||
SYNCS_INCLUDE_PENDING_DEFAULT = begin
|
||||
simplefin = ENV.fetch("SIMPLEFIN_INCLUDE_PENDING", "1") == "1"
|
||||
plaid = ENV.fetch("PLAID_INCLUDE_PENDING", "1") == "1"
|
||||
simplefin && plaid
|
||||
end
|
||||
field :syncs_include_pending, type: :boolean, default: SYNCS_INCLUDE_PENDING_DEFAULT
|
||||
field :auto_sync_enabled, type: :boolean, default: ENV.fetch("AUTO_SYNC_ENABLED", "1") == "1"
|
||||
field :auto_sync_time, type: :string, default: ENV.fetch("AUTO_SYNC_TIME", "02:22")
|
||||
field :auto_sync_timezone, type: :string, default: ENV.fetch("AUTO_SYNC_TIMEZONE", "UTC")
|
||||
|
||||
AUTO_SYNC_TIME_FORMAT = /\A([01]?\d|2[0-3]):([0-5]\d)\z/
|
||||
|
||||
def self.valid_auto_sync_time?(time_str)
|
||||
return false if time_str.blank?
|
||||
AUTO_SYNC_TIME_FORMAT.match?(time_str.to_s.strip)
|
||||
end
|
||||
|
||||
def self.valid_auto_sync_timezone?(timezone_str)
|
||||
return false if timezone_str.blank?
|
||||
ActiveSupport::TimeZone[timezone_str].present?
|
||||
end
|
||||
|
||||
# Dynamic fields are now stored as individual entries with "dynamic:" prefix
|
||||
# This prevents race conditions and ensures each field is independently managed
|
||||
|
||||
|
||||
@@ -46,7 +46,9 @@ class SimplefinAccount::Processor
|
||||
avail = to_decimal(simplefin_account.available_balance)
|
||||
|
||||
# Choose an observed value prioritizing posted balance first
|
||||
observed = bal.nonzero? ? bal : avail
|
||||
# Use available_balance only when current_balance is truly missing (nil),
|
||||
# not when it's explicitly zero (e.g., dormant credit card with no debt)
|
||||
observed = simplefin_account.current_balance.nil? ? avail : bal
|
||||
|
||||
# Determine if this should be treated as a liability for normalization
|
||||
is_linked_liability = [ "CreditCard", "Loan" ].include?(account.accountable_type)
|
||||
|
||||
@@ -34,9 +34,27 @@ class SimplefinEntry::Processor
|
||||
# Include provider-supplied extra hash if present
|
||||
sf["extra"] = data[:extra] if data[:extra].is_a?(Hash)
|
||||
|
||||
# Pending detection: only use explicit provider flag
|
||||
# Pending detection: explicit flag OR inferred from posted=0 + transacted_at
|
||||
# SimpleFIN indicates pending via:
|
||||
# 1. pending: true (explicit flag)
|
||||
# 2. posted=0 (epoch zero) + transacted_at present (implicit - some banks use this pattern)
|
||||
#
|
||||
# Note: We only infer from posted=0, NOT from posted=nil/blank, because some providers
|
||||
# don't supply posted dates even for settled transactions (would cause false positives).
|
||||
# We always set the key (true or false) to ensure deep_merge overwrites any stale value
|
||||
if ActiveModel::Type::Boolean.new.cast(data[:pending])
|
||||
is_pending = if ActiveModel::Type::Boolean.new.cast(data[:pending])
|
||||
true
|
||||
else
|
||||
# Infer pending ONLY when posted is explicitly 0 (epoch) AND transacted_at is present
|
||||
# posted=nil/blank is NOT treated as pending (some providers omit posted for settled txns)
|
||||
posted_val = data[:posted]
|
||||
transacted_val = data[:transacted_at]
|
||||
posted_is_epoch_zero = posted_val.present? && posted_val.to_i.zero?
|
||||
transacted_present = transacted_val.present? && transacted_val.to_i > 0
|
||||
posted_is_epoch_zero && transacted_present
|
||||
end
|
||||
|
||||
if is_pending
|
||||
sf["pending"] = true
|
||||
Rails.logger.debug("SimpleFIN: flagged pending transaction #{external_id}")
|
||||
else
|
||||
|
||||
@@ -55,6 +55,20 @@ class SimplefinItem < ApplicationRecord
|
||||
SimplefinItem::Importer.new(self, simplefin_provider: simplefin_provider, sync: sync).import
|
||||
end
|
||||
|
||||
# Update the access_url by claiming a new setup token.
|
||||
# This is used when reconnecting an existing SimpleFIN connection.
|
||||
# Unlike create_simplefin_item!, this updates in-place, preserving all account linkages.
|
||||
def update_access_url!(setup_token:)
|
||||
new_access_url = simplefin_provider.claim_access_url(setup_token)
|
||||
|
||||
update!(
|
||||
access_url: new_access_url,
|
||||
status: :good
|
||||
)
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
def process_accounts
|
||||
# Process accounts linked via BOTH legacy FK and AccountProvider
|
||||
# Use direct query to ensure fresh data from DB, bypassing any association cache
|
||||
@@ -271,8 +285,8 @@ class SimplefinItem < ApplicationRecord
|
||||
return nil unless latest
|
||||
|
||||
# If sync has statistics, use them
|
||||
if latest.sync_stats.present?
|
||||
stats = latest.sync_stats
|
||||
stats = parse_sync_stats(latest.sync_stats)
|
||||
if stats.present?
|
||||
total = stats["total_accounts"] || 0
|
||||
linked = stats["linked_accounts"] || 0
|
||||
unlinked = stats["unlinked_accounts"] || 0
|
||||
@@ -399,7 +413,68 @@ class SimplefinItem < ApplicationRecord
|
||||
issues
|
||||
end
|
||||
|
||||
# Get reconciled duplicates count from the last sync
|
||||
# Returns { count: N, message: "..." } or { count: 0 } if none
|
||||
def last_sync_reconciled_status
|
||||
latest_sync = syncs.ordered.first
|
||||
return { count: 0 } unless latest_sync
|
||||
|
||||
stats = parse_sync_stats(latest_sync.sync_stats)
|
||||
count = stats&.dig("pending_reconciled").to_i
|
||||
if count > 0
|
||||
{
|
||||
count: count,
|
||||
message: I18n.t("simplefin_items.reconciled_status.message", count: count)
|
||||
}
|
||||
else
|
||||
{ count: 0 }
|
||||
end
|
||||
end
|
||||
|
||||
# Count stale pending transactions (>8 days old) across all linked accounts
|
||||
# Returns { count: N, accounts: [names] } or { count: 0 } if none
|
||||
def stale_pending_status(days: 8)
|
||||
# Get all accounts linked to this SimpleFIN item
|
||||
# Eager-load both association paths to avoid N+1 on current_account method
|
||||
linked_accounts = simplefin_accounts.includes(:account, :linked_account).filter_map(&:current_account)
|
||||
return { count: 0 } if linked_accounts.empty?
|
||||
|
||||
# Batch query to avoid N+1
|
||||
account_ids = linked_accounts.map(&:id)
|
||||
counts_by_account = Entry.stale_pending(days: days)
|
||||
.where(excluded: false)
|
||||
.where(account_id: account_ids)
|
||||
.group(:account_id)
|
||||
.count
|
||||
|
||||
account_counts = linked_accounts
|
||||
.map { |account| { account: account, count: counts_by_account[account.id].to_i } }
|
||||
.select { |ac| ac[:count] > 0 }
|
||||
|
||||
total = account_counts.sum { |ac| ac[:count] }
|
||||
if total > 0
|
||||
{
|
||||
count: total,
|
||||
accounts: account_counts.map { |ac| ac[:account].name },
|
||||
message: I18n.t("simplefin_items.stale_pending_status.message", count: total, days: days)
|
||||
}
|
||||
else
|
||||
{ count: 0 }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
# Parse sync_stats, handling cases where it might be a raw JSON string
|
||||
# (e.g., from console testing or bypassed serialization)
|
||||
def parse_sync_stats(sync_stats)
|
||||
return nil if sync_stats.blank?
|
||||
return sync_stats if sync_stats.is_a?(Hash)
|
||||
|
||||
if sync_stats.is_a?(String)
|
||||
JSON.parse(sync_stats) rescue nil
|
||||
end
|
||||
end
|
||||
|
||||
def remove_simplefin_item
|
||||
# SimpleFin doesn't require server-side cleanup like Plaid
|
||||
# The access URL just becomes inactive
|
||||
|
||||
@@ -9,6 +9,7 @@ class SimplefinItem::Importer
|
||||
@simplefin_provider = simplefin_provider
|
||||
@sync = sync
|
||||
@enqueued_holdings_job_ids = Set.new
|
||||
@reconciled_account_ids = Set.new # Debounce pending reconciliation per run
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -44,6 +45,11 @@ class SimplefinItem::Importer
|
||||
Rails.logger.info "SimplefinItem::Importer - Using REGULAR SYNC (last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')})"
|
||||
import_regular_sync
|
||||
end
|
||||
|
||||
# Reset status to good if no auth errors occurred in this sync.
|
||||
# This allows the item to recover automatically when a bank's auth issue is resolved
|
||||
# in SimpleFIN Bridge, without requiring the user to manually reconnect.
|
||||
maybe_clear_requires_update_status
|
||||
rescue RateLimitedError => e
|
||||
stats["rate_limited"] = true
|
||||
stats["rate_limited_at"] = Time.current.iso8601
|
||||
@@ -201,8 +207,8 @@ class SimplefinItem::Importer
|
||||
end
|
||||
|
||||
adapter.update_balance(
|
||||
balance: normalized,
|
||||
cash_balance: cash,
|
||||
balance: account_data[:balance],
|
||||
cash_balance: account_data[:"available-balance"],
|
||||
source: "simplefin"
|
||||
)
|
||||
end
|
||||
@@ -320,6 +326,21 @@ class SimplefinItem::Importer
|
||||
sync.update_columns(sync_stats: merged) # avoid callbacks/validations during tight loops
|
||||
end
|
||||
|
||||
# Reset status to good if no auth errors occurred in this sync.
|
||||
# This allows automatic recovery when a bank's auth issue is resolved in SimpleFIN Bridge.
|
||||
def maybe_clear_requires_update_status
|
||||
return unless simplefin_item.requires_update?
|
||||
|
||||
auth_errors = stats.dig("error_buckets", "auth").to_i
|
||||
if auth_errors.zero?
|
||||
simplefin_item.update!(status: :good)
|
||||
Rails.logger.info(
|
||||
"SimpleFIN: cleared requires_update status for item ##{simplefin_item.id} " \
|
||||
"(no auth errors in this sync)"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def import_with_chunked_history
|
||||
# SimpleFin's actual limit is 60 days (not 365 as documented)
|
||||
# Use 60-day chunks to stay within limits
|
||||
@@ -428,9 +449,12 @@ class SimplefinItem::Importer
|
||||
perform_account_discovery
|
||||
|
||||
# Step 2: Fetch transactions/holdings using the regular window.
|
||||
# Note: Don't pass explicit `pending:` here - let fetch_accounts_data use the
|
||||
# SIMPLEFIN_INCLUDE_PENDING config. This allows users to disable pending transactions
|
||||
# if their bank's SimpleFIN integration produces duplicates when pending→posted.
|
||||
start_date = determine_sync_start_date
|
||||
Rails.logger.info "SimplefinItem::Importer - import_regular_sync: last_synced_at=#{simplefin_item.last_synced_at&.strftime('%Y-%m-%d %H:%M')} => start_date=#{start_date&.strftime('%Y-%m-%d')}"
|
||||
accounts_data = fetch_accounts_data(start_date: start_date, pending: true)
|
||||
accounts_data = fetch_accounts_data(start_date: start_date)
|
||||
return if accounts_data.nil? # Error already handled
|
||||
|
||||
# Store raw payload
|
||||
@@ -554,9 +578,15 @@ class SimplefinItem::Importer
|
||||
# Returns a Hash payload with keys like :accounts, or nil when an error is
|
||||
# handled internally via `handle_errors`.
|
||||
def fetch_accounts_data(start_date:, end_date: nil, pending: nil)
|
||||
# Determine whether to include pending based on explicit arg or global config.
|
||||
# `Rails.configuration.x.simplefin.include_pending` is ENV-backed.
|
||||
effective_pending = pending.nil? ? Rails.configuration.x.simplefin.include_pending : pending
|
||||
# Determine whether to include pending based on explicit arg, env var, or Setting.
|
||||
# Priority: explicit arg > env var > Setting (allows runtime changes via UI)
|
||||
effective_pending = if !pending.nil?
|
||||
pending
|
||||
elsif ENV["SIMPLEFIN_INCLUDE_PENDING"].present?
|
||||
Rails.configuration.x.simplefin.include_pending
|
||||
else
|
||||
Setting.syncs_include_pending
|
||||
end
|
||||
|
||||
# Debug logging to track exactly what's being sent to SimpleFin API
|
||||
start_str = start_date.respond_to?(:strftime) ? start_date.strftime("%Y-%m-%d") : "none"
|
||||
@@ -806,6 +836,15 @@ class SimplefinItem::Importer
|
||||
# Post-save side effects
|
||||
acct = simplefin_account.current_account
|
||||
if acct
|
||||
# Handle pending transaction reconciliation (debounced per run to avoid
|
||||
# repeated scans during chunked history imports)
|
||||
unless @reconciled_account_ids.include?(acct.id)
|
||||
@reconciled_account_ids << acct.id
|
||||
reconcile_and_track_pending_duplicates(acct)
|
||||
exclude_and_track_stale_pending(acct)
|
||||
track_stale_unmatched_pending(acct)
|
||||
end
|
||||
|
||||
# Refresh credit attributes when available-balance present
|
||||
if acct.accountable_type == "CreditCard" && account_data[:"available-balance"].present?
|
||||
begin
|
||||
@@ -1146,19 +1185,103 @@ class SimplefinItem::Importer
|
||||
ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys
|
||||
end
|
||||
|
||||
# --- Simple helpers for numeric handling in normalization ---
|
||||
def to_decimal(value)
|
||||
return BigDecimal("0") if value.nil?
|
||||
case value
|
||||
when BigDecimal then value
|
||||
when String then BigDecimal(value) rescue BigDecimal("0")
|
||||
when Numeric then BigDecimal(value.to_s)
|
||||
else
|
||||
BigDecimal("0")
|
||||
# Reconcile pending transactions that have a matching posted version
|
||||
# Handles duplicates where pending and posted both exist (tip adjustments, etc.)
|
||||
def reconcile_and_track_pending_duplicates(account)
|
||||
reconcile_stats = Entry.reconcile_pending_duplicates(account: account, dry_run: false)
|
||||
|
||||
exact_matches = reconcile_stats[:details].select { |d| d[:match_type] == "exact" }
|
||||
fuzzy_suggestions = reconcile_stats[:details].select { |d| d[:match_type] == "fuzzy_suggestion" }
|
||||
|
||||
if exact_matches.any?
|
||||
stats["pending_reconciled"] = stats.fetch("pending_reconciled", 0) + exact_matches.size
|
||||
stats["pending_reconciled_details"] ||= []
|
||||
exact_matches.each do |detail|
|
||||
stats["pending_reconciled_details"] << {
|
||||
"account_name" => detail[:account],
|
||||
"pending_name" => detail[:pending_name],
|
||||
"posted_name" => detail[:posted_name]
|
||||
}
|
||||
end
|
||||
stats["pending_reconciled_details"] = stats["pending_reconciled_details"].last(50)
|
||||
end
|
||||
|
||||
if fuzzy_suggestions.any?
|
||||
stats["duplicate_suggestions_created"] = stats.fetch("duplicate_suggestions_created", 0) + fuzzy_suggestions.size
|
||||
stats["duplicate_suggestions_details"] ||= []
|
||||
fuzzy_suggestions.each do |detail|
|
||||
stats["duplicate_suggestions_details"] << {
|
||||
"account_name" => detail[:account],
|
||||
"pending_name" => detail[:pending_name],
|
||||
"posted_name" => detail[:posted_name]
|
||||
}
|
||||
end
|
||||
stats["duplicate_suggestions_details"] = stats["duplicate_suggestions_details"].last(50)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.warn("SimpleFin: pending reconciliation failed for account #{account.id}: #{e.class} - #{e.message}")
|
||||
record_reconciliation_error("pending_reconciliation", account, e)
|
||||
end
|
||||
|
||||
def same_sign?(a, b)
|
||||
(a.positive? && b.positive?) || (a.negative? && b.negative?)
|
||||
# Auto-exclude stale pending transactions (>8 days old with no matching posted version)
|
||||
# Prevents orphaned pending transactions from affecting budgets indefinitely
|
||||
def exclude_and_track_stale_pending(account)
|
||||
excluded_count = Entry.auto_exclude_stale_pending(account: account)
|
||||
return unless excluded_count > 0
|
||||
|
||||
stats["stale_pending_excluded"] = stats.fetch("stale_pending_excluded", 0) + excluded_count
|
||||
stats["stale_pending_details"] ||= []
|
||||
stats["stale_pending_details"] << {
|
||||
"account_name" => account.name,
|
||||
"account_id" => account.id,
|
||||
"count" => excluded_count
|
||||
}
|
||||
stats["stale_pending_details"] = stats["stale_pending_details"].last(50)
|
||||
rescue => e
|
||||
Rails.logger.warn("SimpleFin: stale pending cleanup failed for account #{account.id}: #{e.class} - #{e.message}")
|
||||
record_reconciliation_error("stale_pending_cleanup", account, e)
|
||||
end
|
||||
|
||||
# Track stale pending transactions that couldn't be matched (for user awareness)
|
||||
# These are >8 days old, still pending, and have no duplicate suggestion
|
||||
def track_stale_unmatched_pending(account)
|
||||
stale_unmatched = account.entries
|
||||
.joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'")
|
||||
.where(excluded: false)
|
||||
.where("entries.date < ?", 8.days.ago.to_date)
|
||||
.where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
SQL
|
||||
.where(<<~SQL.squish)
|
||||
transactions.extra -> 'potential_posted_match' IS NULL
|
||||
SQL
|
||||
.count
|
||||
|
||||
return unless stale_unmatched > 0
|
||||
|
||||
stats["stale_unmatched_pending"] = stats.fetch("stale_unmatched_pending", 0) + stale_unmatched
|
||||
stats["stale_unmatched_details"] ||= []
|
||||
stats["stale_unmatched_details"] << {
|
||||
"account_name" => account.name,
|
||||
"account_id" => account.id,
|
||||
"count" => stale_unmatched
|
||||
}
|
||||
stats["stale_unmatched_details"] = stats["stale_unmatched_details"].last(50)
|
||||
rescue => e
|
||||
Rails.logger.warn("SimpleFin: stale unmatched tracking failed for account #{account.id}: #{e.class} - #{e.message}")
|
||||
record_reconciliation_error("stale_unmatched_tracking", account, e)
|
||||
end
|
||||
|
||||
# Record reconciliation errors to sync_stats for UI visibility
|
||||
def record_reconciliation_error(context, account, error)
|
||||
stats["reconciliation_errors"] ||= []
|
||||
stats["reconciliation_errors"] << {
|
||||
"context" => context,
|
||||
"account_id" => account.id,
|
||||
"account_name" => account.name,
|
||||
"error" => "#{error.class}: #{error.message}"
|
||||
}
|
||||
stats["reconciliation_errors"] = stats["reconciliation_errors"].last(20)
|
||||
end
|
||||
end
|
||||
|
||||
108
app/models/sso_audit_log.rb
Normal file
108
app/models/sso_audit_log.rb
Normal file
@@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SsoAuditLog < ApplicationRecord
|
||||
belongs_to :user, optional: true
|
||||
|
||||
# Event types for SSO audit logging
|
||||
EVENT_TYPES = %w[
|
||||
login
|
||||
login_failed
|
||||
logout
|
||||
logout_idp
|
||||
link
|
||||
unlink
|
||||
jit_account_created
|
||||
].freeze
|
||||
|
||||
validates :event_type, presence: true, inclusion: { in: EVENT_TYPES }
|
||||
|
||||
scope :recent, -> { order(created_at: :desc) }
|
||||
scope :for_user, ->(user) { where(user: user) }
|
||||
scope :by_event, ->(event) { where(event_type: event) }
|
||||
|
||||
class << self
|
||||
# Log a successful SSO login
|
||||
def log_login!(user:, provider:, request:, metadata: {})
|
||||
create!(
|
||||
user: user,
|
||||
event_type: "login",
|
||||
provider: provider,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent&.truncate(500),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
# Log a failed SSO login attempt
|
||||
def log_login_failed!(provider:, request:, reason:, metadata: {})
|
||||
create!(
|
||||
user: nil,
|
||||
event_type: "login_failed",
|
||||
provider: provider,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent&.truncate(500),
|
||||
metadata: metadata.merge(reason: reason)
|
||||
)
|
||||
end
|
||||
|
||||
# Log a logout (local only)
|
||||
def log_logout!(user:, request:, metadata: {})
|
||||
create!(
|
||||
user: user,
|
||||
event_type: "logout",
|
||||
provider: nil,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent&.truncate(500),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
# Log a federated logout (to IdP)
|
||||
def log_logout_idp!(user:, provider:, request:, metadata: {})
|
||||
create!(
|
||||
user: user,
|
||||
event_type: "logout_idp",
|
||||
provider: provider,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent&.truncate(500),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
# Log an account link (existing user links SSO identity)
|
||||
def log_link!(user:, provider:, request:, metadata: {})
|
||||
create!(
|
||||
user: user,
|
||||
event_type: "link",
|
||||
provider: provider,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent&.truncate(500),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
# Log an account unlink (user disconnects SSO identity)
|
||||
def log_unlink!(user:, provider:, request:, metadata: {})
|
||||
create!(
|
||||
user: user,
|
||||
event_type: "unlink",
|
||||
provider: provider,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent&.truncate(500),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
# Log JIT account creation via SSO
|
||||
def log_jit_account_created!(user:, provider:, request:, metadata: {})
|
||||
create!(
|
||||
user: user,
|
||||
event_type: "jit_account_created",
|
||||
provider: provider,
|
||||
ip_address: request.remote_ip,
|
||||
user_agent: request.user_agent&.truncate(500),
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
144
app/models/sso_provider.rb
Normal file
144
app/models/sso_provider.rb
Normal file
@@ -0,0 +1,144 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SsoProvider < ApplicationRecord
|
||||
# Encrypt sensitive credentials using Rails 7.2 built-in encryption
|
||||
encrypts :client_secret, deterministic: false
|
||||
|
||||
# Default enabled to true for new providers
|
||||
attribute :enabled, :boolean, default: true
|
||||
|
||||
# Validations
|
||||
validates :strategy, presence: true, inclusion: {
|
||||
in: %w[openid_connect google_oauth2 github saml],
|
||||
message: "%{value} is not a supported strategy"
|
||||
}
|
||||
validates :name, presence: true, uniqueness: true, format: {
|
||||
with: /\A[a-z0-9_]+\z/,
|
||||
message: "must contain only lowercase letters, numbers, and underscores"
|
||||
}
|
||||
validates :label, presence: true
|
||||
validates :enabled, inclusion: { in: [ true, false ] }
|
||||
|
||||
# Strategy-specific validations
|
||||
validate :validate_oidc_fields, if: -> { strategy == "openid_connect" }
|
||||
validate :validate_oauth_fields, if: -> { strategy.in?(%w[google_oauth2 github]) }
|
||||
validate :validate_saml_fields, if: -> { strategy == "saml" }
|
||||
validate :validate_default_role_setting
|
||||
# Note: OIDC discovery validation is done client-side via Stimulus
|
||||
# Server-side validation can fail due to network issues, so we skip it
|
||||
# validate :validate_oidc_discovery, if: -> { strategy == "openid_connect" && issuer.present? && will_save_change_to_issuer? }
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :by_strategy, ->(strategy) { where(strategy: strategy) }
|
||||
|
||||
# Convert to hash format compatible with OmniAuth initializer
|
||||
def to_omniauth_config
|
||||
{
|
||||
id: name,
|
||||
strategy: strategy,
|
||||
name: name,
|
||||
label: label,
|
||||
icon: icon,
|
||||
issuer: issuer,
|
||||
client_id: client_id,
|
||||
client_secret: client_secret,
|
||||
redirect_uri: redirect_uri,
|
||||
settings: settings || {}
|
||||
}.compact
|
||||
end
|
||||
|
||||
private
|
||||
def validate_oidc_fields
|
||||
if issuer.blank?
|
||||
errors.add(:issuer, "is required for OpenID Connect providers")
|
||||
elsif issuer.present? && !valid_url?(issuer)
|
||||
errors.add(:issuer, "must be a valid URL")
|
||||
end
|
||||
|
||||
errors.add(:client_id, "is required for OpenID Connect providers") if client_id.blank?
|
||||
errors.add(:client_secret, "is required for OpenID Connect providers") if client_secret.blank?
|
||||
|
||||
if redirect_uri.present? && !valid_url?(redirect_uri)
|
||||
errors.add(:redirect_uri, "must be a valid URL")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_oauth_fields
|
||||
errors.add(:client_id, "is required for OAuth providers") if client_id.blank?
|
||||
errors.add(:client_secret, "is required for OAuth providers") if client_secret.blank?
|
||||
end
|
||||
|
||||
def validate_saml_fields
|
||||
# SAML requires either a metadata URL or manual configuration
|
||||
idp_metadata_url = settings&.dig("idp_metadata_url")
|
||||
idp_sso_url = settings&.dig("idp_sso_url")
|
||||
|
||||
if idp_metadata_url.blank? && idp_sso_url.blank?
|
||||
errors.add(:settings, "Either IdP Metadata URL or IdP SSO URL is required for SAML providers")
|
||||
end
|
||||
|
||||
# If using manual config, require certificate
|
||||
if idp_metadata_url.blank? && idp_sso_url.present?
|
||||
idp_cert = settings&.dig("idp_certificate")
|
||||
idp_fingerprint = settings&.dig("idp_cert_fingerprint")
|
||||
|
||||
if idp_cert.blank? && idp_fingerprint.blank?
|
||||
errors.add(:settings, "Either IdP Certificate or Certificate Fingerprint is required when not using metadata URL")
|
||||
end
|
||||
end
|
||||
|
||||
# Validate URL formats if provided
|
||||
if idp_metadata_url.present? && !valid_url?(idp_metadata_url)
|
||||
errors.add(:settings, "IdP Metadata URL must be a valid URL")
|
||||
end
|
||||
|
||||
if idp_sso_url.present? && !valid_url?(idp_sso_url)
|
||||
errors.add(:settings, "IdP SSO URL must be a valid URL")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_default_role_setting
|
||||
default_role = settings&.dig("default_role")
|
||||
return if default_role.blank?
|
||||
|
||||
unless User.roles.key?(default_role)
|
||||
errors.add(:settings, "default_role must be member, admin, or super_admin")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_oidc_discovery
|
||||
return unless issuer.present?
|
||||
|
||||
begin
|
||||
discovery_url = issuer.end_with?("/") ? "#{issuer}.well-known/openid-configuration" : "#{issuer}/.well-known/openid-configuration"
|
||||
response = Faraday.get(discovery_url) do |req|
|
||||
req.options.timeout = 5
|
||||
req.options.open_timeout = 3
|
||||
end
|
||||
|
||||
unless response.success?
|
||||
errors.add(:issuer, "discovery endpoint returned #{response.status}")
|
||||
return
|
||||
end
|
||||
|
||||
discovery_data = JSON.parse(response.body)
|
||||
unless discovery_data["issuer"].present?
|
||||
errors.add(:issuer, "discovery endpoint did not return valid issuer")
|
||||
end
|
||||
rescue Faraday::Error => e
|
||||
errors.add(:issuer, "could not connect to discovery endpoint: #{e.message}")
|
||||
rescue JSON::ParserError
|
||||
errors.add(:issuer, "discovery endpoint returned invalid JSON")
|
||||
rescue StandardError => e
|
||||
errors.add(:issuer, "discovery validation failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def valid_url?(url)
|
||||
uri = URI.parse(url)
|
||||
uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
rescue URI::InvalidURIError
|
||||
false
|
||||
end
|
||||
end
|
||||
201
app/models/sso_provider_tester.rb
Normal file
201
app/models/sso_provider_tester.rb
Normal file
@@ -0,0 +1,201 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Tests SSO provider configuration by validating discovery endpoints
|
||||
class SsoProviderTester
|
||||
attr_reader :provider, :result
|
||||
|
||||
Result = Struct.new(:success?, :message, :details, keyword_init: true)
|
||||
|
||||
def initialize(provider)
|
||||
@provider = provider
|
||||
@result = nil
|
||||
end
|
||||
|
||||
def test!
|
||||
@result = case provider.strategy
|
||||
when "openid_connect"
|
||||
test_oidc_discovery
|
||||
when "google_oauth2"
|
||||
test_google_oauth
|
||||
when "github"
|
||||
test_github_oauth
|
||||
when "saml"
|
||||
test_saml_metadata
|
||||
else
|
||||
Result.new(success?: false, message: "Unknown strategy: #{provider.strategy}", details: {})
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def test_oidc_discovery
|
||||
return Result.new(success?: false, message: "Issuer URL is required", details: {}) if provider.issuer.blank?
|
||||
|
||||
discovery_url = build_discovery_url(provider.issuer)
|
||||
|
||||
begin
|
||||
response = Faraday.get(discovery_url) do |req|
|
||||
req.options.timeout = 10
|
||||
req.options.open_timeout = 5
|
||||
end
|
||||
|
||||
unless response.success?
|
||||
return Result.new(
|
||||
success?: false,
|
||||
message: "Discovery endpoint returned HTTP #{response.status}",
|
||||
details: { url: discovery_url, status: response.status }
|
||||
)
|
||||
end
|
||||
|
||||
discovery = JSON.parse(response.body)
|
||||
|
||||
# Validate required OIDC fields
|
||||
required_fields = %w[issuer authorization_endpoint token_endpoint]
|
||||
missing = required_fields.select { |f| discovery[f].blank? }
|
||||
|
||||
if missing.any?
|
||||
return Result.new(
|
||||
success?: false,
|
||||
message: "Discovery document missing required fields: #{missing.join(", ")}",
|
||||
details: { url: discovery_url, missing_fields: missing }
|
||||
)
|
||||
end
|
||||
|
||||
# Check if issuer matches
|
||||
if discovery["issuer"] != provider.issuer && discovery["issuer"] != provider.issuer.chomp("/")
|
||||
return Result.new(
|
||||
success?: false,
|
||||
message: "Issuer mismatch: expected #{provider.issuer}, got #{discovery["issuer"]}",
|
||||
details: { expected: provider.issuer, actual: discovery["issuer"] }
|
||||
)
|
||||
end
|
||||
|
||||
Result.new(
|
||||
success?: true,
|
||||
message: "OIDC discovery validated successfully",
|
||||
details: {
|
||||
issuer: discovery["issuer"],
|
||||
authorization_endpoint: discovery["authorization_endpoint"],
|
||||
token_endpoint: discovery["token_endpoint"],
|
||||
end_session_endpoint: discovery["end_session_endpoint"],
|
||||
scopes_supported: discovery["scopes_supported"]
|
||||
}
|
||||
)
|
||||
|
||||
rescue Faraday::TimeoutError
|
||||
Result.new(success?: false, message: "Connection timed out", details: { url: discovery_url })
|
||||
rescue Faraday::ConnectionFailed => e
|
||||
Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: discovery_url })
|
||||
rescue JSON::ParserError
|
||||
Result.new(success?: false, message: "Invalid JSON response from discovery endpoint", details: { url: discovery_url })
|
||||
rescue StandardError => e
|
||||
Result.new(success?: false, message: "Error: #{e.message}", details: { url: discovery_url })
|
||||
end
|
||||
end
|
||||
|
||||
def test_google_oauth
|
||||
# Google OAuth doesn't require discovery validation - just check credentials present
|
||||
if provider.client_id.blank?
|
||||
return Result.new(success?: false, message: "Client ID is required", details: {})
|
||||
end
|
||||
|
||||
if provider.client_secret.blank?
|
||||
return Result.new(success?: false, message: "Client Secret is required", details: {})
|
||||
end
|
||||
|
||||
Result.new(
|
||||
success?: true,
|
||||
message: "Google OAuth2 configuration looks valid",
|
||||
details: {
|
||||
note: "Full validation occurs during actual authentication"
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def test_github_oauth
|
||||
# GitHub OAuth doesn't require discovery validation - just check credentials present
|
||||
if provider.client_id.blank?
|
||||
return Result.new(success?: false, message: "Client ID is required", details: {})
|
||||
end
|
||||
|
||||
if provider.client_secret.blank?
|
||||
return Result.new(success?: false, message: "Client Secret is required", details: {})
|
||||
end
|
||||
|
||||
Result.new(
|
||||
success?: true,
|
||||
message: "GitHub OAuth configuration looks valid",
|
||||
details: {
|
||||
note: "Full validation occurs during actual authentication"
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def test_saml_metadata
|
||||
# SAML testing - check for IdP metadata or SSO URL
|
||||
if provider.settings&.dig("idp_metadata_url").blank? &&
|
||||
provider.settings&.dig("idp_sso_url").blank?
|
||||
return Result.new(
|
||||
success?: false,
|
||||
message: "Either IdP Metadata URL or IdP SSO URL is required",
|
||||
details: {}
|
||||
)
|
||||
end
|
||||
|
||||
# If metadata URL is provided, try to fetch it
|
||||
metadata_url = provider.settings&.dig("idp_metadata_url")
|
||||
if metadata_url.present?
|
||||
begin
|
||||
response = Faraday.get(metadata_url) do |req|
|
||||
req.options.timeout = 10
|
||||
req.options.open_timeout = 5
|
||||
end
|
||||
|
||||
unless response.success?
|
||||
return Result.new(
|
||||
success?: false,
|
||||
message: "Metadata endpoint returned HTTP #{response.status}",
|
||||
details: { url: metadata_url, status: response.status }
|
||||
)
|
||||
end
|
||||
|
||||
# Basic XML validation
|
||||
unless response.body.include?("<") && response.body.include?("EntityDescriptor")
|
||||
return Result.new(
|
||||
success?: false,
|
||||
message: "Response does not appear to be valid SAML metadata",
|
||||
details: { url: metadata_url }
|
||||
)
|
||||
end
|
||||
|
||||
return Result.new(
|
||||
success?: true,
|
||||
message: "SAML metadata fetched successfully",
|
||||
details: { url: metadata_url }
|
||||
)
|
||||
rescue Faraday::TimeoutError
|
||||
return Result.new(success?: false, message: "Connection timed out", details: { url: metadata_url })
|
||||
rescue Faraday::ConnectionFailed => e
|
||||
return Result.new(success?: false, message: "Connection failed: #{e.message}", details: { url: metadata_url })
|
||||
rescue StandardError => e
|
||||
return Result.new(success?: false, message: "Error: #{e.message}", details: { url: metadata_url })
|
||||
end
|
||||
end
|
||||
|
||||
Result.new(
|
||||
success?: true,
|
||||
message: "SAML configuration looks valid",
|
||||
details: {
|
||||
note: "Full validation occurs during actual authentication"
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def build_discovery_url(issuer)
|
||||
if issuer.end_with?("/")
|
||||
"#{issuer}.well-known/openid-configuration"
|
||||
else
|
||||
"#{issuer}/.well-known/openid-configuration"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,30 @@ class Transaction < ApplicationRecord
|
||||
funds_movement: "funds_movement", # Movement of funds between accounts, excluded from budget analytics
|
||||
cc_payment: "cc_payment", # A CC payment, excluded from budget analytics (CC payments offset the sum of expense transactions)
|
||||
loan_payment: "loan_payment", # A payment to a Loan account, treated as an expense in budgets
|
||||
one_time: "one_time" # A one-time expense/income, excluded from budget analytics
|
||||
one_time: "one_time", # A one-time expense/income, excluded from budget analytics
|
||||
investment_contribution: "investment_contribution" # Transfer to investment/crypto account, excluded from budget analytics
|
||||
}
|
||||
|
||||
# All valid investment activity labels (for UI dropdown)
|
||||
ACTIVITY_LABELS = [
|
||||
"Buy", "Sell", "Sweep In", "Sweep Out", "Dividend", "Reinvestment",
|
||||
"Interest", "Fee", "Transfer", "Contribution", "Withdrawal", "Exchange", "Other"
|
||||
].freeze
|
||||
|
||||
# Pending transaction scopes - filter based on provider pending flags in extra JSONB
|
||||
# Works with any provider that stores pending status in extra["provider_name"]["pending"]
|
||||
scope :pending, -> {
|
||||
where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
SQL
|
||||
}
|
||||
|
||||
scope :excluding_pending, -> {
|
||||
where(<<~SQL.squish)
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
SQL
|
||||
}
|
||||
|
||||
# Overarching grouping method for all transfer-type transactions
|
||||
@@ -42,7 +65,85 @@ class Transaction < ApplicationRecord
|
||||
false
|
||||
end
|
||||
|
||||
# Potential duplicate matching methods
|
||||
# These help users review and resolve fuzzy-matched pending/posted pairs
|
||||
|
||||
def has_potential_duplicate?
|
||||
potential_posted_match_data.present? && !potential_duplicate_dismissed?
|
||||
end
|
||||
|
||||
def potential_duplicate_entry
|
||||
return nil unless has_potential_duplicate?
|
||||
Entry.find_by(id: potential_posted_match_data["entry_id"])
|
||||
end
|
||||
|
||||
def potential_duplicate_reason
|
||||
potential_posted_match_data&.dig("reason")
|
||||
end
|
||||
|
||||
def potential_duplicate_confidence
|
||||
potential_posted_match_data&.dig("confidence") || "medium"
|
||||
end
|
||||
|
||||
def low_confidence_duplicate?
|
||||
potential_duplicate_confidence == "low"
|
||||
end
|
||||
|
||||
def potential_duplicate_posted_amount
|
||||
potential_posted_match_data&.dig("posted_amount")&.to_d
|
||||
end
|
||||
|
||||
def potential_duplicate_dismissed?
|
||||
potential_posted_match_data&.dig("dismissed") == true
|
||||
end
|
||||
|
||||
# Merge this pending transaction with its suggested posted match
|
||||
# This DELETES the pending entry since the posted version is canonical
|
||||
def merge_with_duplicate!
|
||||
return false unless has_potential_duplicate?
|
||||
|
||||
posted_entry = potential_duplicate_entry
|
||||
return false unless posted_entry
|
||||
|
||||
pending_entry_id = entry.id
|
||||
pending_entry_name = entry.name
|
||||
|
||||
# Delete this pending entry completely (no need to keep it around)
|
||||
entry.destroy!
|
||||
|
||||
Rails.logger.info("User merged pending entry #{pending_entry_id} (#{pending_entry_name}) with posted entry #{posted_entry.id}")
|
||||
true
|
||||
end
|
||||
|
||||
# Dismiss the duplicate suggestion - user says these are NOT the same transaction
|
||||
def dismiss_duplicate_suggestion!
|
||||
return false unless potential_posted_match_data.present?
|
||||
|
||||
updated_extra = (extra || {}).deep_dup
|
||||
updated_extra["potential_posted_match"]["dismissed"] = true
|
||||
update!(extra: updated_extra)
|
||||
|
||||
Rails.logger.info("User dismissed duplicate suggestion for entry #{entry.id}")
|
||||
true
|
||||
end
|
||||
|
||||
# Clear the duplicate suggestion entirely
|
||||
def clear_duplicate_suggestion!
|
||||
return false unless potential_posted_match_data.present?
|
||||
|
||||
updated_extra = (extra || {}).deep_dup
|
||||
updated_extra.delete("potential_posted_match")
|
||||
update!(extra: updated_extra)
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def potential_posted_match_data
|
||||
return nil unless extra.is_a?(Hash)
|
||||
extra["potential_posted_match"]
|
||||
end
|
||||
|
||||
def clear_merchant_unlinked_association
|
||||
return unless merchant_id.present? && merchant.is_a?(ProviderMerchant)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ class Transaction::Search
|
||||
attribute :amount, :string
|
||||
attribute :amount_operator, :string
|
||||
attribute :types, array: true
|
||||
attribute :status, array: true
|
||||
attribute :accounts, array: true
|
||||
attribute :account_ids, array: true
|
||||
attribute :start_date, :string
|
||||
@@ -30,6 +31,7 @@ class Transaction::Search
|
||||
query = apply_active_accounts_filter(query, active_accounts_only)
|
||||
query = apply_category_filter(query, categories)
|
||||
query = apply_type_filter(query, types)
|
||||
query = apply_status_filter(query, status)
|
||||
query = apply_merchant_filter(query, merchants)
|
||||
query = apply_tag_filter(query, tags)
|
||||
query = EntrySearch.apply_search_filter(query, search)
|
||||
@@ -47,8 +49,8 @@ class Transaction::Search
|
||||
Rails.cache.fetch("transaction_search_totals/#{cache_key_base}") do
|
||||
result = transactions_scope
|
||||
.select(
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount >= 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as expense_total",
|
||||
"COALESCE(SUM(CASE WHEN entries.amount < 0 AND transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution') THEN ABS(entries.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as income_total",
|
||||
"COUNT(entries.id) as transactions_count"
|
||||
)
|
||||
.joins(
|
||||
@@ -98,14 +100,14 @@ class Transaction::Search
|
||||
if parent_category_ids.empty?
|
||||
query = query.left_joins(:category).where(
|
||||
"categories.name IN (?) OR (
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
|
||||
)",
|
||||
categories
|
||||
)
|
||||
else
|
||||
query = query.left_joins(:category).where(
|
||||
"categories.name IN (?) OR categories.parent_id IN (?) OR (
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment'))
|
||||
categories.id IS NULL AND (transactions.kind NOT IN ('funds_movement', 'cc_payment', 'investment_contribution'))
|
||||
)",
|
||||
categories, parent_category_ids
|
||||
)
|
||||
@@ -153,4 +155,28 @@ class Transaction::Search
|
||||
return query unless tags.present?
|
||||
query.joins(:tags).where(tags: { name: tags })
|
||||
end
|
||||
|
||||
def apply_status_filter(query, statuses)
|
||||
return query unless statuses.present?
|
||||
return query if statuses.uniq.sort == [ "confirmed", "pending" ] # Both selected = no filter
|
||||
|
||||
pending_condition = <<~SQL.squish
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean = true
|
||||
OR (transactions.extra -> 'plaid' ->> 'pending')::boolean = true
|
||||
SQL
|
||||
|
||||
confirmed_condition = <<~SQL.squish
|
||||
(transactions.extra -> 'simplefin' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
AND (transactions.extra -> 'plaid' ->> 'pending')::boolean IS DISTINCT FROM true
|
||||
SQL
|
||||
|
||||
case statuses.sort
|
||||
when [ "pending" ]
|
||||
query.where(pending_condition)
|
||||
when [ "confirmed" ]
|
||||
query.where(confirmed_condition)
|
||||
else
|
||||
query
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,6 +16,10 @@ class Transfer < ApplicationRecord
|
||||
def kind_for_account(account)
|
||||
if account.loan?
|
||||
"loan_payment"
|
||||
elsif account.credit_card?
|
||||
"cc_payment"
|
||||
elsif account.investment? || account.crypto?
|
||||
"investment_contribution"
|
||||
elsif account.liability?
|
||||
"cc_payment"
|
||||
else
|
||||
|
||||
53
app/policies/application_policy.rb
Normal file
53
app/policies/application_policy.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ApplicationPolicy
|
||||
attr_reader :user, :record
|
||||
|
||||
def initialize(user, record)
|
||||
@user = user
|
||||
@record = record
|
||||
end
|
||||
|
||||
def index?
|
||||
false
|
||||
end
|
||||
|
||||
def show?
|
||||
false
|
||||
end
|
||||
|
||||
def create?
|
||||
false
|
||||
end
|
||||
|
||||
def new?
|
||||
create?
|
||||
end
|
||||
|
||||
def update?
|
||||
false
|
||||
end
|
||||
|
||||
def edit?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
false
|
||||
end
|
||||
|
||||
class Scope
|
||||
def initialize(user, scope)
|
||||
@user = user
|
||||
@scope = scope
|
||||
end
|
||||
|
||||
def resolve
|
||||
raise NoMethodError, "You must define #resolve in #{self.class}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :scope
|
||||
end
|
||||
end
|
||||
50
app/policies/sso_provider_policy.rb
Normal file
50
app/policies/sso_provider_policy.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SsoProviderPolicy < ApplicationPolicy
|
||||
# Only super admins can manage SSO providers (instance-wide auth config)
|
||||
def index?
|
||||
user&.super_admin?
|
||||
end
|
||||
|
||||
def show?
|
||||
user&.super_admin?
|
||||
end
|
||||
|
||||
def create?
|
||||
user&.super_admin?
|
||||
end
|
||||
|
||||
def new?
|
||||
create?
|
||||
end
|
||||
|
||||
def update?
|
||||
user&.super_admin?
|
||||
end
|
||||
|
||||
def edit?
|
||||
update?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user&.super_admin?
|
||||
end
|
||||
|
||||
def toggle?
|
||||
update?
|
||||
end
|
||||
|
||||
def test_connection?
|
||||
user&.super_admin?
|
||||
end
|
||||
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
def resolve
|
||||
if user&.super_admin?
|
||||
scope.all
|
||||
else
|
||||
scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
24
app/policies/user_policy.rb
Normal file
24
app/policies/user_policy.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UserPolicy < ApplicationPolicy
|
||||
# Only super_admins can manage user roles
|
||||
def index?
|
||||
user&.super_admin?
|
||||
end
|
||||
|
||||
def update?
|
||||
return false unless user&.super_admin?
|
||||
# Prevent users from changing their own role (must be done by another super_admin)
|
||||
user.id != record.id
|
||||
end
|
||||
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
def resolve
|
||||
if user&.super_admin?
|
||||
scope.all
|
||||
else
|
||||
scope.none
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
52
app/services/auto_sync_scheduler.rb
Normal file
52
app/services/auto_sync_scheduler.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class AutoSyncScheduler
|
||||
JOB_NAME = "sync_all_accounts"
|
||||
|
||||
def self.sync!
|
||||
Rails.logger.info("[AutoSyncScheduler] auto_sync_enabled=#{Setting.auto_sync_enabled}, time=#{Setting.auto_sync_time}")
|
||||
if Setting.auto_sync_enabled?
|
||||
upsert_job
|
||||
else
|
||||
remove_job
|
||||
end
|
||||
end
|
||||
|
||||
def self.upsert_job
|
||||
time_str = Setting.auto_sync_time || "02:22"
|
||||
timezone_str = Setting.auto_sync_timezone || "UTC"
|
||||
|
||||
unless Setting.valid_auto_sync_time?(time_str)
|
||||
Rails.logger.error("[AutoSyncScheduler] Invalid time format: #{time_str}, using default 02:22")
|
||||
time_str = "02:22"
|
||||
end
|
||||
|
||||
hour, minute = time_str.split(":").map(&:to_i)
|
||||
timezone = ActiveSupport::TimeZone[timezone_str] || ActiveSupport::TimeZone["UTC"]
|
||||
local_time = timezone.now.change(hour: hour, min: minute, sec: 0)
|
||||
utc_time = local_time.utc
|
||||
|
||||
cron = "#{utc_time.min} #{utc_time.hour} * * *"
|
||||
|
||||
job = Sidekiq::Cron::Job.create(
|
||||
name: JOB_NAME,
|
||||
cron: cron,
|
||||
class: "SyncAllJob",
|
||||
queue: "scheduled",
|
||||
description: "Syncs all accounts for all families"
|
||||
)
|
||||
|
||||
if job.nil? || (job.respond_to?(:valid?) && !job.valid?)
|
||||
error_msg = job.respond_to?(:errors) ? job.errors.to_a.join(", ") : "unknown error"
|
||||
Rails.logger.error("[AutoSyncScheduler] Failed to create cron job: #{error_msg}")
|
||||
raise StandardError, "Failed to create sync schedule: #{error_msg}"
|
||||
end
|
||||
|
||||
Rails.logger.info("[AutoSyncScheduler] Created cron job with schedule: #{cron} (#{time_str} #{timezone_str})")
|
||||
job
|
||||
end
|
||||
|
||||
def self.remove_job
|
||||
if (job = Sidekiq::Cron::Job.find(JOB_NAME))
|
||||
job.destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
87
app/services/provider_loader.rb
Normal file
87
app/services/provider_loader.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Service class to load SSO provider configurations from either YAML or database
|
||||
# based on the :db_sso_providers feature flag.
|
||||
#
|
||||
# Usage:
|
||||
# providers = ProviderLoader.load_providers
|
||||
#
|
||||
class ProviderLoader
|
||||
CACHE_KEY = "sso_providers_config"
|
||||
CACHE_EXPIRES_IN = 5.minutes
|
||||
|
||||
class << self
|
||||
# Load providers from either DB or YAML based on feature flag
|
||||
# Returns an array of provider configuration hashes
|
||||
def load_providers
|
||||
# Check cache first for performance
|
||||
cached = Rails.cache.read(CACHE_KEY)
|
||||
return cached if cached.present?
|
||||
|
||||
providers = if use_database_providers?
|
||||
load_from_database
|
||||
else
|
||||
load_from_yaml
|
||||
end
|
||||
|
||||
# Cache the result
|
||||
Rails.cache.write(CACHE_KEY, providers, expires_in: CACHE_EXPIRES_IN)
|
||||
providers
|
||||
end
|
||||
|
||||
# Clear the provider cache (call after updating providers in admin)
|
||||
def clear_cache
|
||||
Rails.cache.delete(CACHE_KEY)
|
||||
end
|
||||
|
||||
private
|
||||
def use_database_providers?
|
||||
return false if Rails.env.test?
|
||||
|
||||
begin
|
||||
# Check if feature exists, create if not (defaults to disabled)
|
||||
unless Flipper.exist?(:db_sso_providers)
|
||||
Flipper.add(:db_sso_providers)
|
||||
end
|
||||
Flipper.enabled?(:db_sso_providers)
|
||||
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid, StandardError => e
|
||||
# Database not ready or other error, fall back to YAML
|
||||
Rails.logger.warn("[ProviderLoader] Could not check feature flag (#{e.class}), falling back to YAML providers")
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def load_from_database
|
||||
begin
|
||||
providers = SsoProvider.enabled.order(:name).map(&:to_omniauth_config)
|
||||
|
||||
if providers.empty?
|
||||
Rails.logger.info("[ProviderLoader] No enabled providers in database, falling back to YAML")
|
||||
return load_from_yaml
|
||||
end
|
||||
|
||||
Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from database")
|
||||
providers
|
||||
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError => e
|
||||
Rails.logger.error("[ProviderLoader] Database error loading providers: #{e.message}, falling back to YAML")
|
||||
load_from_yaml
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[ProviderLoader] Unexpected error loading providers from database: #{e.message}, falling back to YAML")
|
||||
load_from_yaml
|
||||
end
|
||||
end
|
||||
|
||||
def load_from_yaml
|
||||
begin
|
||||
auth_config = Rails.application.config_for(:auth)
|
||||
providers = auth_config.dig("providers") || []
|
||||
|
||||
Rails.logger.info("[ProviderLoader] Loaded #{providers.count} provider(s) from YAML")
|
||||
providers
|
||||
rescue RuntimeError, Errno::ENOENT => e
|
||||
Rails.logger.error("[ProviderLoader] Error loading auth.yml: #{e.message}")
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,17 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# DEPRECATED: This thin wrapper remains only for backward compatibility.
|
||||
# Business logic has moved into `SimplefinItem::Unlinking` (model concern).
|
||||
# Prefer calling `item.unlink_all!(dry_run: ...)` directly.
|
||||
class SimplefinItem::Unlinker
|
||||
attr_reader :item, :dry_run
|
||||
|
||||
def initialize(item, dry_run: false)
|
||||
@item = item
|
||||
@dry_run = dry_run
|
||||
end
|
||||
|
||||
def unlink_all!
|
||||
item.unlink_all!(dry_run: dry_run)
|
||||
end
|
||||
end
|
||||
280
app/views/admin/sso_providers/_form.html.erb
Normal file
280
app/views/admin/sso_providers/_form.html.erb
Normal file
@@ -0,0 +1,280 @@
|
||||
<%# locals: (sso_provider:) %>
|
||||
|
||||
<% if sso_provider.errors.any? %>
|
||||
<div class="bg-destructive/10 border border-destructive rounded-lg p-4 mb-4">
|
||||
<div class="flex">
|
||||
<%= icon "alert-circle", class: "w-5 h-5 text-destructive mr-2 shrink-0" %>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-destructive">
|
||||
<%= pluralize(sso_provider.errors.count, "error") %> prohibited this provider from being saved:
|
||||
</p>
|
||||
<ul class="mt-2 text-sm text-destructive list-disc list-inside">
|
||||
<% sso_provider.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= styled_form_with model: [:admin, sso_provider], class: "space-y-6", data: { controller: "admin-sso-form" } do |form| %>
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-medium text-primary">Basic Information</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<%= form.select :strategy,
|
||||
options_for_select([
|
||||
["OpenID Connect", "openid_connect"],
|
||||
["SAML 2.0", "saml"],
|
||||
["Google OAuth2", "google_oauth2"],
|
||||
["GitHub", "github"]
|
||||
], sso_provider.strategy),
|
||||
{ label: "Strategy" },
|
||||
{ data: { action: "change->admin-sso-form#toggleFields" } } %>
|
||||
|
||||
<%= form.text_field :name,
|
||||
label: "Name",
|
||||
placeholder: "e.g., keycloak, authentik",
|
||||
required: true,
|
||||
data: { action: "input->admin-sso-form#updateCallbackUrl" } %>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2">Unique identifier (lowercase, numbers, underscores only)</p>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<%= form.text_field :label,
|
||||
label: "Button Label",
|
||||
placeholder: "e.g., Sign in with Keycloak",
|
||||
required: true %>
|
||||
|
||||
<div>
|
||||
<%= form.text_field :icon,
|
||||
label: "Icon (optional)",
|
||||
placeholder: "e.g., key, shield" %>
|
||||
<p class="text-xs text-secondary mt-1">Lucide icon name for the login button</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-primary"><%= t("admin.sso_providers.form.enabled_label") %></p>
|
||||
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.enabled_help") %></p>
|
||||
</div>
|
||||
<%= form.toggle :enabled %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-primary pt-4 space-y-4">
|
||||
<h3 class="font-medium text-primary">OAuth/OIDC Configuration</h3>
|
||||
|
||||
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
|
||||
<%= form.text_field :issuer,
|
||||
label: "Issuer URL",
|
||||
placeholder: "https://your-idp.example.com/realms/your-realm",
|
||||
data: { action: "blur->admin-sso-form#validateIssuer" } %>
|
||||
<p class="text-xs text-secondary mt-1">OIDC issuer URL (validates .well-known/openid-configuration)</p>
|
||||
</div>
|
||||
|
||||
<%= form.text_field :client_id,
|
||||
label: "Client ID",
|
||||
placeholder: "your-client-id",
|
||||
required: true %>
|
||||
|
||||
<%= form.password_field :client_secret,
|
||||
label: "Client Secret",
|
||||
placeholder: sso_provider.persisted? ? "••••••••" : "your-client-secret",
|
||||
required: !sso_provider.persisted? %>
|
||||
<% if sso_provider.persisted? %>
|
||||
<p class="text-xs text-secondary -mt-2">Leave blank to keep existing secret</p>
|
||||
<% end %>
|
||||
|
||||
<div data-oidc-field class="<%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
|
||||
<label class="block text-sm font-medium text-primary mb-1">Callback URL</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
|
||||
data-admin-sso-form-target="callbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
|
||||
<button type="button"
|
||||
data-action="click->admin-sso-form#copyCallback"
|
||||
class="p-2 text-secondary hover:text-primary shrink-0"
|
||||
title="Copy to clipboard">
|
||||
<%= icon "copy", class: "w-4 h-4" %>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-secondary mt-1">Configure this URL in your identity provider</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-saml-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "saml" %>">
|
||||
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.saml_configuration") %></h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_metadata_url") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_metadata_url]"
|
||||
value="<%= sso_provider.settings&.dig("idp_metadata_url") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="https://idp.example.com/metadata"
|
||||
autocomplete="off">
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_metadata_url_help") %></p>
|
||||
</div>
|
||||
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.manual_saml_config") %></summary>
|
||||
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
|
||||
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.manual_saml_help") %></p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_sso_url") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_sso_url]"
|
||||
value="<%= sso_provider.settings&.dig("idp_sso_url") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="https://idp.example.com/sso"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_slo_url") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_slo_url]"
|
||||
value="<%= sso_provider.settings&.dig("idp_slo_url") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="https://idp.example.com/slo (optional)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_certificate") %></label>
|
||||
<textarea name="sso_provider[settings][idp_certificate]"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
|
||||
placeholder="-----BEGIN CERTIFICATE-----"><%= sso_provider.settings&.dig("idp_certificate") %></textarea>
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.idp_certificate_help") %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.idp_cert_fingerprint") %></label>
|
||||
<input type="text" name="sso_provider[settings][idp_cert_fingerprint]"
|
||||
value="<%= sso_provider.settings&.dig("idp_cert_fingerprint") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm font-mono"
|
||||
placeholder="AB:CD:EF:..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.name_id_format") %></label>
|
||||
<select name="sso_provider[settings][name_id_format]"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm">
|
||||
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" <%= "selected" if sso_provider.settings&.dig("name_id_format").blank? || sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" %>><%= t("admin.sso_providers.form.name_id_email") %></option>
|
||||
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" %>><%= t("admin.sso_providers.form.name_id_persistent") %></option>
|
||||
<option value="urn:oasis:names:tc:SAML:2.0:nameid-format:transient" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" %>><%= t("admin.sso_providers.form.name_id_transient") %></option>
|
||||
<option value="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" <%= "selected" if sso_provider.settings&.dig("name_id_format") == "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" %>><%= t("admin.sso_providers.form.name_id_unspecified") %></option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1">SP Callback URL (ACS URL)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="flex-1 bg-surface px-3 py-2 rounded text-sm text-secondary overflow-x-auto"
|
||||
data-admin-sso-form-target="samlCallbackUrl"><%= "#{request.base_url}/auth/#{sso_provider.name.presence || 'PROVIDER_NAME'}/callback" %></code>
|
||||
<button type="button"
|
||||
data-action="click->admin-sso-form#copySamlCallback"
|
||||
class="p-2 text-secondary hover:text-primary shrink-0"
|
||||
title="Copy to clipboard">
|
||||
<%= icon "copy", class: "w-4 h-4" %>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-secondary mt-1">Configure this URL as the Assertion Consumer Service URL in your IdP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-primary pt-4 space-y-4">
|
||||
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.provisioning_title") %></h3>
|
||||
|
||||
<%= form.select "settings[default_role]",
|
||||
options_for_select([
|
||||
[t("admin.sso_providers.form.role_member"), "member"],
|
||||
[t("admin.sso_providers.form.role_admin"), "admin"],
|
||||
[t("admin.sso_providers.form.role_super_admin"), "super_admin"]
|
||||
], sso_provider.settings&.dig("default_role") || "member"),
|
||||
{ label: t("admin.sso_providers.form.default_role_label"), include_blank: false } %>
|
||||
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.default_role_help") %></p>
|
||||
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm font-medium text-secondary hover:text-primary"><%= t("admin.sso_providers.form.role_mapping_title") %></summary>
|
||||
<div class="mt-3 space-y-3 pl-4 border-l-2 border-secondary/30">
|
||||
<p class="text-xs text-secondary"><%= t("admin.sso_providers.form.role_mapping_help") %></p>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.super_admin_groups") %></label>
|
||||
<input type="text" name="sso_provider[settings][role_mapping][super_admin]"
|
||||
value="<%= Array(sso_provider.settings&.dig("role_mapping", "super_admin")).join(", ") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="Platform-Admins, IdP-Superusers"
|
||||
autocomplete="off">
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.groups_help") %></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.admin_groups") %></label>
|
||||
<input type="text" name="sso_provider[settings][role_mapping][admin]"
|
||||
value="<%= Array(sso_provider.settings&.dig("role_mapping", "admin")).join(", ") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="Team-Leads, Managers"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.member_groups") %></label>
|
||||
<input type="text" name="sso_provider[settings][role_mapping][member]"
|
||||
value="<%= Array(sso_provider.settings&.dig("role_mapping", "member")).join(", ") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="* (all groups)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div data-oidc-field class="border-t border-primary pt-4 space-y-4 <%= "hidden" unless sso_provider.strategy == "openid_connect" %>">
|
||||
<h3 class="font-medium text-primary"><%= t("admin.sso_providers.form.advanced_title") %></h3>
|
||||
|
||||
<div>
|
||||
<%= form.text_field "settings[scopes]",
|
||||
label: t("admin.sso_providers.form.scopes_label"),
|
||||
value: sso_provider.settings&.dig("scopes"),
|
||||
placeholder: "openid email profile groups" %>
|
||||
<p class="text-xs text-secondary mt-1"><%= t("admin.sso_providers.form.scopes_help") %></p>
|
||||
</div>
|
||||
|
||||
<%= form.select "settings[prompt]",
|
||||
options_for_select([
|
||||
[t("admin.sso_providers.form.prompt_default"), ""],
|
||||
[t("admin.sso_providers.form.prompt_login"), "login"],
|
||||
[t("admin.sso_providers.form.prompt_consent"), "consent"],
|
||||
[t("admin.sso_providers.form.prompt_select_account"), "select_account"],
|
||||
[t("admin.sso_providers.form.prompt_none"), "none"]
|
||||
], sso_provider.settings&.dig("prompt")),
|
||||
{ label: t("admin.sso_providers.form.prompt_label"), include_blank: false } %>
|
||||
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.prompt_help") %></p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center gap-3 pt-4 border-t border-primary">
|
||||
<div>
|
||||
<% if sso_provider.persisted? %>
|
||||
<button type="button"
|
||||
data-action="click->admin-sso-form#testConnection"
|
||||
data-admin-sso-form-test-url-value="<%= test_connection_admin_sso_provider_path(sso_provider) %>"
|
||||
class="px-4 py-2 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg">
|
||||
<%= t("admin.sso_providers.form.test_connection") %>
|
||||
</button>
|
||||
<span data-admin-sso-form-target="testResult" class="ml-2 text-sm"></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<%= link_to "Cancel", admin_sso_providers_path, class: "px-4 py-2 text-sm font-medium text-secondary hover:text-primary" %>
|
||||
<%= form.submit sso_provider.persisted? ? "Update Provider" : "Create Provider",
|
||||
class: "px-4 py-2 bg-primary text-inverse rounded-lg text-sm font-medium hover:bg-primary/90" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
9
app/views/admin/sso_providers/edit.html.erb
Normal file
9
app/views/admin/sso_providers/edit.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<%= content_for :page_title, "Edit #{@sso_provider.label}" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary">Update configuration for <%= @sso_provider.label %>.</p>
|
||||
|
||||
<%= settings_section title: "Provider Configuration" do %>
|
||||
<%= render "form", sso_provider: @sso_provider %>
|
||||
<% end %>
|
||||
</div>
|
||||
126
app/views/admin/sso_providers/index.html.erb
Normal file
126
app/views/admin/sso_providers/index.html.erb
Normal file
@@ -0,0 +1,126 @@
|
||||
<%= content_for :page_title, "SSO Providers" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary mb-4">
|
||||
Manage single sign-on authentication providers for your instance.
|
||||
<% unless Flipper.enabled?(:db_sso_providers) %>
|
||||
<span class="text-warning">Changes require a server restart to take effect.</span>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<%= settings_section title: "Configured Providers" do %>
|
||||
<% if @sso_providers.any? %>
|
||||
<div class="divide-y divide-primary">
|
||||
<% @sso_providers.each do |provider| %>
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<% if provider.icon.present? %>
|
||||
<%= icon provider.icon, class: "w-5 h-5 text-secondary" %>
|
||||
<% else %>
|
||||
<%= icon "key", class: "w-5 h-5 text-secondary" %>
|
||||
<% end %>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= provider.label %></p>
|
||||
<p class="text-sm text-secondary"><%= provider.strategy.titleize %> · <%= provider.name %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<% if provider.enabled? %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Enabled
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
|
||||
Disabled
|
||||
</span>
|
||||
<% end %>
|
||||
<%= link_to edit_admin_sso_provider_path(provider), class: "p-1 text-secondary hover:text-primary", title: "Edit" do %>
|
||||
<%= icon "pencil", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
<%= button_to toggle_admin_sso_provider_path(provider), method: :patch, class: "p-1 text-secondary hover:text-primary", title: provider.enabled? ? "Disable" : "Enable", form: { data: { turbo_confirm: "Are you sure you want to #{provider.enabled? ? 'disable' : 'enable'} this provider?" } } do %>
|
||||
<%= icon provider.enabled? ? "toggle-right" : "toggle-left", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
<%= button_to admin_sso_provider_path(provider), method: :delete, class: "p-1 text-destructive hover:text-destructive", title: "Delete", form: { data: { turbo_confirm: "Are you sure you want to delete this provider? This action cannot be undone." } } do %>
|
||||
<%= icon "trash-2", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="text-center py-6">
|
||||
<%= icon "key", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
|
||||
<p class="text-secondary">No SSO providers configured yet.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pt-4 border-t border-primary">
|
||||
<%= link_to new_admin_sso_provider_path, class: "inline-flex items-center gap-2 text-sm font-medium text-primary hover:text-secondary" do %>
|
||||
<%= icon "plus", class: "w-4 h-4" %>
|
||||
Add Provider
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @legacy_providers.any? %>
|
||||
<%= settings_section title: t("admin.sso_providers.index.legacy_providers_title"), collapsible: true, open: true do %>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4">
|
||||
<div class="flex gap-2">
|
||||
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0" %>
|
||||
<p class="text-sm text-amber-800">
|
||||
<%= t("admin.sso_providers.index.legacy_providers_notice") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-primary">
|
||||
<% @legacy_providers.each do |provider| %>
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<% provider_icon = provider[:icon].presence || "key" %>
|
||||
<%= icon provider_icon, class: "w-5 h-5 text-secondary" %>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= provider[:label].presence || provider[:name] %></p>
|
||||
<p class="text-sm text-secondary">
|
||||
<%= provider[:strategy].to_s.titleize %> · <%= provider[:name] %>
|
||||
<% if provider[:issuer].present? %>
|
||||
· <span class="text-xs"><%= provider[:issuer] %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
|
||||
<%= t("admin.sso_providers.index.env_configured") %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: "Configuration Mode", collapsible: true, open: false do %>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-primary">Database-backed providers</p>
|
||||
<p class="text-sm text-secondary">Load providers from database instead of YAML config</p>
|
||||
</div>
|
||||
<% if Flipper.enabled?(:db_sso_providers) %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Enabled
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-secondary">
|
||||
Disabled
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Set <code class="bg-surface px-1 py-0.5 rounded text-xs">AUTH_PROVIDERS_SOURCE=db</code> to enable database-backed providers.
|
||||
This allows changes without server restarts.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
9
app/views/admin/sso_providers/new.html.erb
Normal file
9
app/views/admin/sso_providers/new.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<%= content_for :page_title, "Add SSO Provider" %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary">Configure a new single sign-on authentication provider.</p>
|
||||
|
||||
<%= settings_section title: "Provider Configuration" do %>
|
||||
<%= render "form", sso_provider: @sso_provider %>
|
||||
<% end %>
|
||||
</div>
|
||||
73
app/views/admin/users/index.html.erb
Normal file
73
app/views/admin/users/index.html.erb
Normal file
@@ -0,0 +1,73 @@
|
||||
<%= content_for :page_title, t(".title") %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<p class="text-secondary"><%= t(".description") %></p>
|
||||
|
||||
<%= settings_section title: t(".section_title") do %>
|
||||
<div class="divide-y divide-primary">
|
||||
<% @users.each do |user| %>
|
||||
<div class="flex items-center justify-between py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-surface flex items-center justify-center">
|
||||
<span class="text-sm font-medium text-primary"><%= user.initials %></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= user.display_name %></p>
|
||||
<p class="text-sm text-secondary"><%= user.email %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<% if user.id == Current.user.id %>
|
||||
<span class="text-sm text-secondary"><%= t(".you") %></span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary">
|
||||
<%= t(".roles.#{user.role}") %>
|
||||
</span>
|
||||
<% else %>
|
||||
<%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %>
|
||||
<%= form.select :role,
|
||||
options_for_select([
|
||||
[t(".roles.member"), "member"],
|
||||
[t(".roles.admin"), "admin"],
|
||||
[t(".roles.super_admin"), "super_admin"]
|
||||
], user.role),
|
||||
{},
|
||||
class: "text-sm rounded-lg border-primary bg-container text-primary px-2 py-1",
|
||||
onchange: "this.form.requestSubmit()" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @users.empty? %>
|
||||
<div class="text-center py-6">
|
||||
<%= icon "users", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
|
||||
<p class="text-secondary"><%= t(".no_users") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".role_descriptions_title"), collapsible: true, open: false do %>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
|
||||
<%= t(".roles.member") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.member") %></p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
|
||||
<%= t(".roles.admin") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.admin") %></p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 shrink-0">
|
||||
<%= t(".roles.super_admin") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.super_admin") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
109
app/views/holdings/_cost_basis_cell.html.erb
Normal file
109
app/views/holdings/_cost_basis_cell.html.erb
Normal file
@@ -0,0 +1,109 @@
|
||||
<%# locals: (holding:, editable: true) %>
|
||||
|
||||
<%
|
||||
# Pre-calculate values for the form
|
||||
# Note: cost_basis field stores per-share cost, so calculate total for display
|
||||
current_per_share = holding.cost_basis.present? && holding.cost_basis.positive? ? holding.cost_basis : nil
|
||||
current_total = current_per_share && holding.qty.positive? ? (current_per_share * holding.qty).round(2) : nil
|
||||
currency = Money::Currency.new(holding.currency)
|
||||
%>
|
||||
|
||||
<%= turbo_frame_tag dom_id(holding, :cost_basis) do %>
|
||||
<% if holding.cost_basis_locked? && !editable %>
|
||||
<%# Locked and not editable (from holdings list) - just show value, right-aligned %>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<%= tag.span format_money(holding.avg_cost) %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%# Unlocked OR editable context (drawer) - show clickable menu %>
|
||||
<%= render DS::Menu.new(variant: :button, placement: "bottom-end") do |menu| %>
|
||||
<% menu.with_button(class: "hover:text-primary cursor-pointer group") do %>
|
||||
<% if holding.avg_cost %>
|
||||
<div class="flex items-center gap-1">
|
||||
<%= tag.span format_money(holding.avg_cost) %>
|
||||
<% if holding.cost_basis_locked? %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<%= icon "pencil", size: "xs", class: "text-secondary opacity-0 group-hover:opacity-100 transition-opacity" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex items-center gap-1 px-2 py-0.5 rounded text-secondary hover:text-primary hover:bg-gray-100 theme-dark:hover:bg-gray-700 transition-colors">
|
||||
<%= icon "pencil", size: "xs" %>
|
||||
<span class="text-xs">Set</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% menu.with_custom_content do %>
|
||||
<div class="p-4 min-w-[280px]"
|
||||
data-controller="cost-basis-form"
|
||||
data-cost-basis-form-qty-value="<%= holding.qty %>">
|
||||
<h4 class="font-medium text-sm mb-3">
|
||||
<%= t(".set_cost_basis_header", ticker: holding.ticker, qty: number_with_precision(holding.qty, precision: 2)) %>
|
||||
</h4>
|
||||
<%
|
||||
form_data = { turbo: false }
|
||||
if holding.avg_cost
|
||||
form_data[:turbo_confirm] = {
|
||||
title: t(".overwrite_confirm_title"),
|
||||
body: t(".overwrite_confirm_body", current: format_money(holding.avg_cost))
|
||||
}
|
||||
end
|
||||
%>
|
||||
<%= styled_form_with model: holding,
|
||||
url: holding_path(holding),
|
||||
method: :patch,
|
||||
class: "space-y-3",
|
||||
data: form_data do |f| %>
|
||||
<!-- Primary: Total cost basis (custom input, no spinners) -->
|
||||
<div class="form-field">
|
||||
<div class="form-field__body">
|
||||
<label class="form-field__label"><%= t(".total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
data-action="input->cost-basis-form#updatePerShare"
|
||||
data-cost-basis-form-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2" data-cost-basis-form-target="perShareDisplay">
|
||||
= <%= currency.symbol %><span data-cost-basis-form-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t(".per_share") %>
|
||||
</p>
|
||||
|
||||
<!-- Alternative: Per-share input -->
|
||||
<div class="pt-2 border-t border-tertiary">
|
||||
<label class="text-xs text-secondary block mb-1"><%= t(".or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
data-action="input->cost-basis-form#updateTotal"
|
||||
data-cost-basis-form-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
||||
data-action="click->DS--menu#close">
|
||||
<%= t(".cancel") %>
|
||||
</button>
|
||||
<%= f.submit t(".save"), class: "inline-flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%= tag.p format_money holding.avg_cost %>
|
||||
<%= render "holdings/cost_basis_cell", holding: holding, editable: false %>
|
||||
<%= tag.p t(".per_share"), class: "font-normal text-secondary" %>
|
||||
</div>
|
||||
|
||||
@@ -45,13 +45,13 @@
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 text-right">
|
||||
<%# Show Total Return (unrealized G/L) when cost basis exists %>
|
||||
<% if holding.trades.any? && holding.trend %>
|
||||
<%# Show Total Return (unrealized G/L) when cost basis exists (from trades or manual) %>
|
||||
<% if holding.trend %>
|
||||
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
|
||||
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
|
||||
<% else %>
|
||||
<%= tag.p "--", class: "text-secondary" %>
|
||||
<%= tag.p "No cost basis", class: "text-xs text-secondary" %>
|
||||
<%= tag.p t(".no_cost_basis"), class: "text-xs text-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,16 +35,107 @@
|
||||
<dd class="text-primary"><%= @holding.weight ? number_to_percentage(@holding.weight, precision: 2) : t(".unknown") %></dd>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
|
||||
<dd class="text-primary"><%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %></dd>
|
||||
<%# Average Cost with inline editor %>
|
||||
<%
|
||||
currency = Money::Currency.new(@holding.currency)
|
||||
current_per_share = @holding.cost_basis.present? && @holding.cost_basis.positive? ? @holding.cost_basis : nil
|
||||
current_total = current_per_share && @holding.qty.positive? ? (current_per_share * @holding.qty).round(2) : nil
|
||||
%>
|
||||
<div data-controller="drawer-cost-basis" data-drawer-cost-basis-qty-value="<%= @holding.qty %>">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".avg_cost_label") %></dt>
|
||||
<dd class="text-primary flex items-center gap-1">
|
||||
<%= @holding.avg_cost ? format_money(@holding.avg_cost) : t(".unknown") %>
|
||||
<% if @holding.cost_basis_locked? %>
|
||||
<%= icon "lock", size: "xs", class: "text-secondary" %>
|
||||
<% end %>
|
||||
<% if @holding.cost_basis_source.present? %>
|
||||
<span class="text-xs text-secondary">(<%= @holding.cost_basis_source_label %>)</span>
|
||||
<% end %>
|
||||
<button type="button" class="ml-1" data-action="click->drawer-cost-basis#toggle">
|
||||
<%= icon "pencil", size: "xs", class: "text-secondary hover:text-primary" %>
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<%# Inline cost basis editor (hidden by default) %>
|
||||
<div class="hidden mt-3 space-y-3" data-drawer-cost-basis-target="form">
|
||||
<%
|
||||
drawer_form_data = { turbo: false }
|
||||
if @holding.avg_cost
|
||||
drawer_form_data[:turbo_confirm] = {
|
||||
title: t("holdings.cost_basis_cell.overwrite_confirm_title"),
|
||||
body: t("holdings.cost_basis_cell.overwrite_confirm_body", current: format_money(@holding.avg_cost))
|
||||
}
|
||||
end
|
||||
%>
|
||||
<%= styled_form_with model: @holding,
|
||||
url: holding_path(@holding),
|
||||
method: :patch,
|
||||
class: "space-y-3",
|
||||
data: drawer_form_data do |f| %>
|
||||
<p class="text-xs text-secondary mb-2">
|
||||
<%= t("holdings.cost_basis_cell.set_cost_basis_header", ticker: @holding.ticker, qty: number_with_precision(@holding.qty, precision: 4)) %>
|
||||
</p>
|
||||
<!-- Total cost basis input -->
|
||||
<div class="form-field">
|
||||
<div class="form-field__body">
|
||||
<label class="form-field__label"><%= t("holdings.cost_basis_cell.total_cost_basis_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
name="holding[cost_basis]"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_total, precision: 2) if current_total %>"
|
||||
data-action="input->drawer-cost-basis#updatePerShare"
|
||||
data-drawer-cost-basis-target="total">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary -mt-2">
|
||||
= <%= currency.symbol %><span data-drawer-cost-basis-target="perShareValue"><%= number_with_precision(current_per_share, precision: 2) || "0.00" %></span> <%= t("holdings.cost_basis_cell.per_share") %>
|
||||
</p>
|
||||
|
||||
<!-- Per-share input -->
|
||||
<div class="pt-2 border-t border-tertiary">
|
||||
<label class="text-xs text-secondary block mb-1"><%= t("holdings.cost_basis_cell.or_per_share_label") %></label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-secondary text-sm font-medium"><%= currency.symbol %></span>
|
||||
<input type="text" inputmode="decimal"
|
||||
class="form-field__input grow"
|
||||
placeholder="0.00"
|
||||
autocomplete="off"
|
||||
value="<%= number_with_precision(current_per_share, precision: 2) if current_per_share %>"
|
||||
data-action="input->drawer-cost-basis#updateTotal"
|
||||
data-drawer-cost-basis-target="perShare">
|
||||
<span class="text-secondary text-sm"><%= currency.iso_code %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button"
|
||||
class="inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600"
|
||||
data-action="click->drawer-cost-basis#toggle">
|
||||
<%= t("holdings.cost_basis_cell.cancel") %>
|
||||
</button>
|
||||
<%= f.submit t("holdings.cost_basis_cell.save"), class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-inverse bg-inverse hover:bg-inverse-hover" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<dt class="text-secondary"><%= t(".total_return_label") %></dt>
|
||||
<dd style="color: <%= @holding.trend&.color %>;">
|
||||
<%= @holding.trend ? render("shared/trend_change", trend: @holding.trend) : t(".unknown") %>
|
||||
</dd>
|
||||
<% if @holding.trend %>
|
||||
<dd style="color: <%= @holding.trend.color %>;">
|
||||
<%= render("shared/trend_change", trend: @holding.trend) %>
|
||||
</dd>
|
||||
<% else %>
|
||||
<dd class="text-secondary"><%= t(".unknown") %></dd>
|
||||
<% end %>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -85,21 +176,39 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @holding.account.can_delete_holdings? %>
|
||||
<% if @holding.cost_basis_locked? || @holding.account.can_delete_holdings? %>
|
||||
<% dialog.with_section(title: t(".settings"), open: true) do %>
|
||||
<div class="pb-4">
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
<% if @holding.cost_basis_locked? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3 border-b border-tertiary">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".cost_basis_locked_label") %></h4>
|
||||
<p class="text-secondary"><%= t(".cost_basis_locked_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
holding_path(@holding),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
||||
data: { turbo_confirm: true } %>
|
||||
</div>
|
||||
<%= button_to t(".unlock_cost_basis"),
|
||||
unlock_cost_basis_holding_path(@holding),
|
||||
method: :post,
|
||||
class: "inline-flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-primary bg-gray-200 hover:bg-gray-300 theme-dark:bg-gray-700 theme-dark:hover:bg-gray-600",
|
||||
form: { data: { turbo: false } },
|
||||
data: { turbo_confirm: { title: t(".unlock_confirm_title"), body: t(".unlock_confirm_body") } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @holding.account.can_delete_holdings? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
holding_path(@holding),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-secondary",
|
||||
data: { turbo_confirm: true } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<div data-drag-and-drop-import-target="overlay" class="fixed inset-0 bg-primary/20 backdrop-blur-sm z-50 hidden flex items-center justify-center pointer-events-none">
|
||||
<div class="text-center p-8 bg-container rounded-xl shadow-2xl border-2 border-dashed border-primary animate-in fade-in zoom-in duration-200">
|
||||
<%= icon("upload", size: "xl", class: "text-primary mb-4 mx-auto w-16 h-16") %>
|
||||
<h3 class="text-2xl font-semibold text-primary mb-2"><%= title %></h3>
|
||||
<p class="text-secondary text-base"><%= subtitle %></p>
|
||||
<div data-drag-and-drop-import-target="overlay" class="fixed inset-0 bg-overlay backdrop-blur-sm z-50 hidden flex items-center justify-center pointer-events-none">
|
||||
<div class="bg-container rounded-xl shadow-border-xs p-6 max-w-sm w-full mx-4 animate-in fade-in zoom-in duration-200">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="w-12 h-12 rounded-full bg-indigo-500/10 flex items-center justify-center mb-4">
|
||||
<%= icon("upload", class: "text-indigo-500 w-6 h-6") %>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-primary mb-1"><%= title %></h3>
|
||||
<p class="text-sm text-secondary"><%= subtitle %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
data-app-layout-user-id-value="<%= Current.user.id %>">
|
||||
<div
|
||||
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
|
||||
data-app-layout-target="mobileSidebar"
|
||||
data-print="hide">
|
||||
data-app-layout-target="mobileSidebar">
|
||||
<div class="mb-2">
|
||||
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
|
||||
</div>
|
||||
@@ -34,7 +33,7 @@
|
||||
</div>
|
||||
|
||||
<%# MOBILE - Top nav %>
|
||||
<nav class="lg:hidden flex justify-between items-center p-3" data-print="hide">
|
||||
<nav class="lg:hidden flex justify-between items-center p-3">
|
||||
<%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
|
||||
|
||||
<%= link_to root_path, class: "block" do %>
|
||||
@@ -45,7 +44,7 @@
|
||||
</nav>
|
||||
|
||||
<%# DESKTOP - Left navbar %>
|
||||
<div class="hidden lg:block" data-print="hide">
|
||||
<div class="hidden lg:block">
|
||||
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4 mr-3">
|
||||
<div class="pl-2 mb-3">
|
||||
<%= link_to root_path, class: "block" do %>
|
||||
@@ -79,7 +78,7 @@
|
||||
"hidden lg:block py-4 overflow-y-auto shrink-0 max-w-[320px] transition-all duration-300",
|
||||
Current.user.show_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
|
||||
),
|
||||
data: { app_layout_target: "leftSidebar", print: "hide" } do %>
|
||||
data: { app_layout_target: "leftSidebar" } do %>
|
||||
<% if content_for?(:sidebar) %>
|
||||
<%= yield :sidebar %>
|
||||
<% else %>
|
||||
@@ -139,7 +138,7 @@
|
||||
"hidden lg:block h-full overflow-y-auto shrink-0 max-w-[400px] transition-all duration-300",
|
||||
Current.user.show_ai_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
|
||||
),
|
||||
data: { app_layout_target: "rightSidebar", print: "hide" } do %>
|
||||
data: { app_layout_target: "rightSidebar" } do %>
|
||||
<%= tag.div id: "chat-container", class: "relative h-full", data: { controller: "chat hotkey", turbo_permanent: true } do %>
|
||||
<div class="flex flex-col h-full justify-between shrink-0">
|
||||
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
|
||||
@@ -158,7 +157,7 @@
|
||||
<% end %>
|
||||
|
||||
<%# MOBILE - Bottom Nav %>
|
||||
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 pb-[env(safe-area-inset-bottom)] border-t border-tertiary flex justify-around", data: { print: "hide" } do %>
|
||||
<%= tag.nav class: "lg:hidden fixed bottom-0 left-0 right-0 bg-surface z-10 pb-[env(safe-area-inset-bottom)] border-t border-tertiary flex justify-around" do %>
|
||||
<% mobile_nav_items.each do |nav_item| %>
|
||||
<%= render "layouts/shared/nav_item", **nav_item %>
|
||||
<% end %>
|
||||
|
||||
28
app/views/layouts/print.html.erb
Normal file
28
app/views/layouts/print.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="font-sans">
|
||||
<head>
|
||||
<title><%= content_for(:title) || t("reports.print.document_title") %></title>
|
||||
|
||||
<%= csrf_meta_tags %>
|
||||
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
</head>
|
||||
|
||||
<body class="bg-white text-gray-900 antialiased print-body">
|
||||
<div class="print-container">
|
||||
<%= yield %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Auto-trigger print dialog when page loads
|
||||
window.onload = function() {
|
||||
// Small delay to ensure styles are loaded
|
||||
setTimeout(function() {
|
||||
window.print();
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -119,7 +119,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<% investment_metrics[:accounts].each do |account| %>
|
||||
<div class="bg-container-inset rounded-lg p-4 flex items-center justify-between">
|
||||
<%= link_to account_path(account), class: "bg-container-inset rounded-lg p-4 flex items-center justify-between hover:bg-container-hover transition-colors" do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= render "accounts/logo", account: account, size: "sm" %>
|
||||
<div>
|
||||
@@ -128,7 +128,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="font-medium text-primary"><%= format_money(account.balance_money) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div>
|
||||
<%# Export Controls %>
|
||||
<div class="reports-print-hide flex items-center justify-end mb-4 flex-wrap gap-3">
|
||||
<div class="flex items-center justify-end mb-4 flex-wrap gap-3">
|
||||
<%
|
||||
# Build params hash for links
|
||||
base_params = {
|
||||
|
||||
@@ -79,4 +79,3 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,103 +1,5 @@
|
||||
<% content_for :head do %>
|
||||
<style media="print">
|
||||
/* Print-specific custom property overrides for values using --alpha() */
|
||||
:root {
|
||||
--print-shadow-color: rgba(11, 11, 11, 0.06);
|
||||
}
|
||||
|
||||
@page {
|
||||
margin: 0.75in;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-gray-50);
|
||||
color: var(--color-gray-900);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
#notification-tray,
|
||||
#cta,
|
||||
#chat-container,
|
||||
turbo-frame#modal,
|
||||
turbo-frame#drawer,
|
||||
[data-print="hide"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-controller="app-layout"] {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
[data-app-layout-target="content"] {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
main {
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
[data-app-layout-target="content"] > .hidden.lg\:flex {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.reports-page {
|
||||
background: transparent;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.reports-print-sheet {
|
||||
background: var(--color-white);
|
||||
border-radius: 16px;
|
||||
padding: 0.75in 0.65in;
|
||||
box-shadow: 0 10px 25px var(--print-shadow-color);
|
||||
}
|
||||
|
||||
.reports-page section {
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.reports-print-hide,
|
||||
[data-reports-section-target="button"],
|
||||
[data-reports-sortable-target="handle"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-reports-section-target="content"] {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
section[data-section-key="transactions_breakdown"] {
|
||||
break-before: page;
|
||||
page-break-before: always;
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
.reports-page table th,
|
||||
.reports-page table td {
|
||||
padding-inline: 0.5rem !important;
|
||||
}
|
||||
</style>
|
||||
<% end %>
|
||||
|
||||
<% content_for :page_header do %>
|
||||
<div class="space-y-4 mb-6 reports-print-hide">
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-xl lg:text-3xl font-medium text-primary">
|
||||
<%= t("reports.index.title") %>
|
||||
@@ -115,42 +17,54 @@
|
||||
<% end %>
|
||||
|
||||
<%# Period Navigation Tabs %>
|
||||
<div class="reports-print-hide flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.monthly"),
|
||||
variant: @period_type == :monthly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :monthly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.quarterly"),
|
||||
variant: @period_type == :quarterly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :quarterly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.ytd"),
|
||||
variant: @period_type == :ytd ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :ytd),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.last_6_months"),
|
||||
variant: @period_type == :last_6_months ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :last_6_months),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.custom"),
|
||||
variant: @period_type == :custom ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :custom),
|
||||
size: :sm
|
||||
) %>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 overflow-x-auto pb-2">
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.monthly"),
|
||||
variant: @period_type == :monthly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :monthly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.quarterly"),
|
||||
variant: @period_type == :quarterly ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :quarterly),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.ytd"),
|
||||
variant: @period_type == :ytd ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :ytd),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.last_6_months"),
|
||||
variant: @period_type == :last_6_months ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :last_6_months),
|
||||
size: :sm
|
||||
) %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("reports.index.periods.custom"),
|
||||
variant: @period_type == :custom ? "secondary" : "ghost",
|
||||
href: reports_path(period_type: :custom),
|
||||
size: :sm
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<%# Print Report Button %>
|
||||
<%= link_to print_reports_path(period_type: @period_type, start_date: @start_date, end_date: @end_date),
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
aria: { label: t("reports.index.print_report") },
|
||||
class: "inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-secondary bg-surface-inset hover:bg-surface-hover rounded-lg transition-colors flex-shrink-0" do %>
|
||||
<%= icon("printer", size: "sm") %>
|
||||
<span class="hidden sm:inline"><%= t("reports.index.print_report") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Custom Date Range Picker (only shown when custom is selected) %>
|
||||
<% if @period_type == :custom %>
|
||||
<%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "reports-print-hide flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
|
||||
<%= form_with url: reports_path, method: :get, data: { controller: "auto-submit-form" }, class: "flex items-center gap-3 bg-surface-inset p-3 rounded-lg" do |f| %>
|
||||
<%= f.hidden_field :period_type, value: :custom %>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -182,7 +96,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="reports-page w-full space-y-6 pb-24 reports-print-sheet">
|
||||
<div class="w-full space-y-6 pb-24">
|
||||
<% if Current.family.transactions.any? %>
|
||||
<%# Summary Dashboard - Always visible, not collapsible %>
|
||||
<section>
|
||||
|
||||
345
app/views/reports/print.html.erb
Normal file
345
app/views/reports/print.html.erb
Normal file
@@ -0,0 +1,345 @@
|
||||
<% content_for :title do %>
|
||||
<%= t("reports.print.document_title") %> - <%= @start_date.strftime("%B %d, %Y") %> to <%= @end_date.strftime("%B %d, %Y") %>
|
||||
<% end %>
|
||||
|
||||
<div class="tufte-report">
|
||||
<%# Header %>
|
||||
<header class="tufte-header">
|
||||
<h1 class="tufte-title"><%= t("reports.print.title") %></h1>
|
||||
<span class="tufte-period"><%= @start_date.strftime("%B %d, %Y") %> – <%= @end_date.strftime("%B %d, %Y") %></span>
|
||||
<p class="tufte-meta"><%= Current.family.name %> · <%= t("reports.print.generated_on", date: Time.current.strftime("%B %d, %Y")) %></p>
|
||||
</header>
|
||||
|
||||
<%# Summary %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.summary.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.income") %></span>
|
||||
<span class="tufte-metric-card-value tufte-income"><%= @summary_metrics[:current_income].format %></span>
|
||||
<% if @summary_metrics[:income_change] && @summary_metrics[:income_change] != 0 %>
|
||||
<span class="tufte-metric-card-change <%= @summary_metrics[:income_change] >= 0 ? "tufte-up" : "tufte-down" %>">
|
||||
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:income_change] >= 0 ? "+#{@summary_metrics[:income_change]}" : @summary_metrics[:income_change].to_s)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:income] }, width: 60, height: 20) %>" fill="none" stroke="#047857" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.expenses") %></span>
|
||||
<span class="tufte-metric-card-value tufte-expense"><%= @summary_metrics[:current_expenses].format %></span>
|
||||
<% if @summary_metrics[:expense_change] && @summary_metrics[:expense_change] != 0 %>
|
||||
<span class="tufte-metric-card-change <%= @summary_metrics[:expense_change] >= 0 ? "tufte-down" : "tufte-up" %>">
|
||||
<%= t("reports.print.summary.vs_prior", percent: (@summary_metrics[:expense_change] >= 0 ? "+#{@summary_metrics[:expense_change]}" : @summary_metrics[:expense_change].to_s)) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:expenses] }, width: 60, height: 20) %>" fill="none" stroke="#b91c1c" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.net_savings") %></span>
|
||||
<span class="tufte-metric-card-value <%= @summary_metrics[:net_savings] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= @summary_metrics[:net_savings].format %></span>
|
||||
<%
|
||||
# Calculate savings rate
|
||||
savings_rate = @summary_metrics[:current_income].amount > 0 ? ((@summary_metrics[:net_savings].amount / @summary_metrics[:current_income].amount) * 100).round(0) : 0
|
||||
%>
|
||||
<% if savings_rate != 0 %>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.of_income", percent: savings_rate) %></span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(@trends_data.map { |t| t[:net] }, width: 60, height: 20) %>" fill="none" stroke="<%= @summary_metrics[:net_savings] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @summary_metrics[:budget_percent] %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.summary.budget") %></span>
|
||||
<span class="tufte-metric-card-value"><%= @summary_metrics[:budget_percent] %>%</span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.summary.used") %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%# Net Worth %>
|
||||
<% if @has_accounts %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.net_worth.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.net_worth.current_balance") %></span>
|
||||
<span class="tufte-metric-card-value <%= @net_worth_metrics[:current_net_worth] >= 0 ? "tufte-income" : "tufte-expense" %>">
|
||||
<%= @net_worth_metrics[:current_net_worth].format %>
|
||||
</span>
|
||||
<% if @net_worth_metrics[:trend] %>
|
||||
<span class="tufte-metric-card-change" style="color: <%= @net_worth_metrics[:trend].color %>">
|
||||
<%= @net_worth_metrics[:trend].value >= 0 ? "+" : "" %><%= @net_worth_metrics[:trend].value.format %> (<%= @net_worth_metrics[:trend].percent_formatted %>) <%= t("reports.print.net_worth.this_period") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="80" height="24" viewBox="0 0 80 24" style="display:block;margin-top:8px;">
|
||||
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 80, height: 24) %>" fill="none" stroke="<%= @net_worth_metrics[:current_net_worth] >= 0 ? "#047857" : "#b91c1c" %>" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tufte-two-col">
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.assets") %> <span class="tufte-income"><%= @net_worth_metrics[:total_assets].format %></span></h3>
|
||||
<% if @net_worth_metrics[:asset_groups].any? %>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<tbody>
|
||||
<% @net_worth_metrics[:asset_groups].each do |group| %>
|
||||
<tr>
|
||||
<td><%= group[:name] %></td>
|
||||
<td class="tufte-right"><%= group[:total].format %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.net_worth.liabilities") %> <span class="tufte-expense"><%= @net_worth_metrics[:total_liabilities].format %></span></h3>
|
||||
<% if @net_worth_metrics[:liability_groups].any? %>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<tbody>
|
||||
<% @net_worth_metrics[:liability_groups].each do |group| %>
|
||||
<tr>
|
||||
<td><%= group[:name] %></td>
|
||||
<td class="tufte-right"><%= group[:total].format %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="tufte-muted" style="margin: 0;"><%= t("reports.print.net_worth.no_liabilities") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Monthly Trends %>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.trends.title") %></h2>
|
||||
<table class="tufte-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.trends.month") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.income") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.expenses") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.net") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.trends.savings_rate") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @trends_data.each do |trend| %>
|
||||
<tr class="<%= trend[:is_current_month] ? "tufte-highlight" : "" %>">
|
||||
<td><%= trend[:month] %><%= trend[:is_current_month] ? " *" : "" %></td>
|
||||
<td class="tufte-right tufte-income"><%= Money.new(trend[:income], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-expense"><%= Money.new(trend[:expenses], Current.family.currency).format %></td>
|
||||
<td class="tufte-right <%= trend[:net] >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(trend[:net], Current.family.currency).format %></td>
|
||||
<td class="tufte-right">
|
||||
<% month_savings_rate = trend[:income] > 0 ? ((trend[:net].to_f / trend[:income].to_f) * 100).round(0) : 0 %>
|
||||
<%= month_savings_rate %>%
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<%
|
||||
total_income = @trends_data.sum { |t| t[:income].to_d }
|
||||
total_expenses = @trends_data.sum { |t| t[:expenses].to_d }
|
||||
total_net = @trends_data.sum { |t| t[:net].to_d }
|
||||
trends_count = @trends_data.length
|
||||
avg_income = trends_count > 0 ? (total_income / trends_count) : 0
|
||||
avg_expenses = trends_count > 0 ? (total_expenses / trends_count) : 0
|
||||
avg_net = trends_count > 0 ? (total_net / trends_count) : 0
|
||||
overall_savings_rate = total_income > 0 ? ((total_net / total_income) * 100).round(0) : 0
|
||||
%>
|
||||
<tr>
|
||||
<td><%= t("reports.print.trends.average") %></td>
|
||||
<td class="tufte-right tufte-income"><%= Money.new(avg_income, Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-expense"><%= Money.new(avg_expenses, Current.family.currency).format %></td>
|
||||
<td class="tufte-right <%= avg_net >= 0 ? "tufte-income" : "tufte-expense" %>"><%= Money.new(avg_net, Current.family.currency).format %></td>
|
||||
<td class="tufte-right"><%= overall_savings_rate %>%</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<% if @trends_data.any? { |t| t[:is_current_month] } %>
|
||||
<p class="tufte-footnote"><%= t("reports.print.trends.current_month_note") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Investments %>
|
||||
<% if @investment_metrics[:has_investments] %>
|
||||
<section class="tufte-section">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.investments.title") %></h2>
|
||||
<div class="tufte-metric-row">
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.portfolio_value") %></span>
|
||||
<span class="tufte-metric-card-value"><%= format_money(@investment_metrics[:portfolio_value]) %></span>
|
||||
<% if has_sparkline_data?(@trends_data) %>
|
||||
<svg width="60" height="20" viewBox="0 0 60 20" style="display:block;margin-top:6px;">
|
||||
<polyline points="<%= sparkline_points(cumulative_net_values(@trends_data), width: 60, height: 20) %>" fill="none" stroke="#6366f1" stroke-width="1.5" />
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @investment_metrics[:unrealized_trend] %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.total_return") %></span>
|
||||
<span class="tufte-metric-card-value" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
|
||||
<%= @investment_metrics[:unrealized_trend].value >= 0 ? "+" : "" %><%= format_money(Money.new(@investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
|
||||
</span>
|
||||
<span class="tufte-metric-card-change" style="color: <%= @investment_metrics[:unrealized_trend].color %>">
|
||||
<%= @investment_metrics[:unrealized_trend].percent_formatted %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.contributions") %></span>
|
||||
<span class="tufte-metric-card-value tufte-income"><%= format_money(@investment_metrics[:period_contributions]) %></span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
|
||||
</div>
|
||||
<div class="tufte-metric-card tufte-metric-card-sm">
|
||||
<span class="tufte-metric-card-label"><%= t("reports.print.investments.withdrawals") %></span>
|
||||
<span class="tufte-metric-card-value tufte-expense"><%= format_money(@investment_metrics[:period_withdrawals]) %></span>
|
||||
<span class="tufte-metric-card-change" style="color: #666;"><%= t("reports.print.investments.this_period") %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @investment_metrics[:top_holdings].any? %>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.investments.top_holdings") %></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.investments.holding") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.weight") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.value") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.investments.return") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @investment_metrics[:top_holdings].each do |holding| %>
|
||||
<tr>
|
||||
<td><strong><%= holding.ticker %></strong> <span class="tufte-muted"><%= truncate(holding.name, length: 25) %></span></td>
|
||||
<td class="tufte-right"><%= number_to_percentage(holding.weight || 0, precision: 1) %></td>
|
||||
<td class="tufte-right"><%= format_money(holding.amount_money) %></td>
|
||||
<td class="tufte-right">
|
||||
<% if holding.trend %>
|
||||
<span style="color: <%= holding.trend.color %>"><%= holding.trend.percent_formatted %></span>
|
||||
<% else %>
|
||||
<span class="tufte-muted">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<%# Spending by Category %>
|
||||
<% if @transactions.any? %>
|
||||
<section class="tufte-section tufte-keep-together">
|
||||
<h2 class="tufte-section-title"><%= t("reports.print.spending.title") %></h2>
|
||||
<%
|
||||
income_groups = @transactions.select { |g| g[:type] == "income" }
|
||||
expense_groups = @transactions.select { |g| g[:type] == "expense" }
|
||||
income_total = income_groups.sum { |g| g[:total] }
|
||||
expense_total = expense_groups.sum { |g| g[:total] }
|
||||
%>
|
||||
|
||||
<div class="tufte-two-col">
|
||||
<% if income_groups.any? %>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.spending.income") %> <span class="tufte-income"><%= Money.new(income_total, Current.family.currency).format %></span></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.spending.category") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% income_groups.first(8).each do |group| %>
|
||||
<% percentage = income_total.zero? ? 0 : (group[:total].to_f / income_total * 100).round(0) %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
|
||||
<%= group[:category_name] %>
|
||||
</td>
|
||||
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if income_groups.length > 8 %>
|
||||
<tr>
|
||||
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: income_groups.length - 8) %></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if expense_groups.any? %>
|
||||
<div>
|
||||
<h3 class="tufte-subsection"><%= t("reports.print.spending.expenses") %> <span class="tufte-expense"><%= Money.new(expense_total, Current.family.currency).format %></span></h3>
|
||||
<table class="tufte-table tufte-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("reports.print.spending.category") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.amount") %></th>
|
||||
<th class="tufte-right"><%= t("reports.print.spending.percent") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% expense_groups.first(8).each do |group| %>
|
||||
<% percentage = expense_total.zero? ? 0 : (group[:total].to_f / expense_total * 100).round(0) %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="tufte-dot" style="background: <%= group[:category_color] %>"></span>
|
||||
<%= group[:category_name] %>
|
||||
</td>
|
||||
<td class="tufte-right"><%= Money.new(group[:total], Current.family.currency).format %></td>
|
||||
<td class="tufte-right tufte-muted"><%= percentage %>%</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if expense_groups.length > 8 %>
|
||||
<tr>
|
||||
<td class="tufte-muted"><%= t("reports.print.spending.more_categories", count: expense_groups.length - 8) %></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<footer class="tufte-footer">
|
||||
<%= product_name %> · <%= @start_date.strftime("%B %Y") %> – <%= @end_date.strftime("%B %Y") %>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -30,7 +30,9 @@ nav_sections = [
|
||||
{ label: t(".api_keys_label"), path: settings_api_key_path, icon: "key" },
|
||||
{ label: t(".self_hosting_label"), path: settings_hosting_path, icon: "database", if: self_hosted? },
|
||||
{ label: "Providers", path: settings_providers_path, icon: "plug" },
|
||||
{ label: t(".imports_label"), path: imports_path, icon: "download" }
|
||||
{ label: t(".imports_label"), path: imports_path, icon: "download" },
|
||||
{ label: "SSO Providers", path: admin_sso_providers_path, icon: "key-round", if: Current.user&.super_admin? },
|
||||
{ label: "Users", path: admin_users_path, icon: "users", if: Current.user&.super_admin? }
|
||||
]
|
||||
} : nil
|
||||
),
|
||||
|
||||
64
app/views/settings/hostings/_sync_settings.html.erb
Normal file
64
app/views/settings/hostings/_sync_settings.html.erb
Normal file
@@ -0,0 +1,64 @@
|
||||
<% env_configured = ENV["SIMPLEFIN_INCLUDE_PENDING"].present? || ENV["PLAID_INCLUDE_PENDING"].present? %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".include_pending_label") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".include_pending_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: Setting.new,
|
||||
url: settings_hosting_path,
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.toggle :syncs_include_pending,
|
||||
checked: Setting.syncs_include_pending,
|
||||
disabled: env_configured,
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".auto_sync_label") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".auto_sync_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: Setting.new,
|
||||
url: settings_hosting_path,
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.toggle :auto_sync_enabled,
|
||||
checked: Setting.auto_sync_enabled,
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm"><%= t(".auto_sync_time_label") %></p>
|
||||
<p class="text-secondary text-sm"><%= t(".auto_sync_time_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= form_with model: Setting.new,
|
||||
url: settings_hosting_path,
|
||||
method: :patch,
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.time_field :auto_sync_time,
|
||||
value: Setting.auto_sync_time,
|
||||
disabled: !Setting.auto_sync_enabled,
|
||||
class: "rounded-lg border border-primary px-3 py-2 text-sm bg-container text-primary w-full",
|
||||
data: { auto_submit_form_target: "auto" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if env_configured %>
|
||||
<div class="bg-warning-50 border border-warning-200 rounded-lg p-3">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon("alert-circle", class: "w-5 h-5 text-warning-600 mt-0.5 shrink-0") %>
|
||||
<p class="text-sm text-warning-800">
|
||||
<%= t(".env_configured_message") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -16,6 +16,9 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".sync_settings") do %>
|
||||
<%= render "settings/hostings/sync_settings" %>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".invites") do %>
|
||||
<%= render "settings/hostings/invite_code_settings" %>
|
||||
<% end %>
|
||||
|
||||
@@ -44,3 +44,58 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @oidc_identities.any? || AuthConfig.sso_providers.any? %>
|
||||
<%= settings_section title: t(".sso_title"), subtitle: t(".sso_subtitle") do %>
|
||||
<% if @oidc_identities.any? %>
|
||||
<div class="space-y-2">
|
||||
<% @oidc_identities.each do |identity| %>
|
||||
<div class="flex items-center justify-between bg-container p-4 shadow-border-xs rounded-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 shrink-0 bg-surface rounded-full flex items-center justify-center">
|
||||
<%= icon identity.provider_config&.dig(:icon) || "key", class: "w-5 h-5 text-secondary" %>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-primary"><%= identity.provider_config&.dig(:label) || identity.provider.titleize %></p>
|
||||
<p class="text-sm text-secondary"><%= identity.info&.dig("email") || t(".sso_no_email") %></p>
|
||||
<p class="text-xs text-secondary">
|
||||
<%= t(".sso_last_used") %>:
|
||||
<%= identity.last_authenticated_at&.to_fs(:short) || t(".sso_never") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% if @oidc_identities.count > 1 || Current.user.password_digest.present? %>
|
||||
<%= render DS::Button.new(
|
||||
text: t(".sso_disconnect"),
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
href: settings_sso_identity_path(identity),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.new(
|
||||
title: t(".sso_confirm_title"),
|
||||
body: t(".sso_confirm_body", provider: identity.provider_config&.dig(:label) || identity.provider.titleize),
|
||||
btn_text: t(".sso_confirm_button"),
|
||||
destructive: true
|
||||
)
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @oidc_identities.count == 1 && Current.user.password_digest.blank? %>
|
||||
<div class="mt-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<%= icon "alert-triangle", class: "w-5 h-5 text-amber-600 shrink-0 mt-0.5" %>
|
||||
<p class="text-sm text-amber-800"><%= t(".sso_warning_message") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-6">
|
||||
<%= icon "link", class: "w-12 h-12 mx-auto text-secondary mb-3" %>
|
||||
<p class="text-secondary"><%= t(".sso_no_identities") %></p>
|
||||
<p class="text-sm text-secondary mt-2"><%= t(".sso_connect_hint") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -104,6 +104,25 @@
|
||||
<%= icon "alert-circle", size: "sm", color: "warning" %>
|
||||
<%= tag.span stale_status[:message], class: "text-sm" %>
|
||||
</div>
|
||||
<% elsif (pending_status = simplefin_item.stale_pending_status)[:count] > 0 %>
|
||||
<div class="text-secondary">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= icon "clock", size: "sm", color: "secondary" %>
|
||||
<%= tag.span pending_status[:message], class: "text-sm" %>
|
||||
<span class="text-xs text-tertiary"><%= t(".stale_pending_note") %></span>
|
||||
</div>
|
||||
<% if pending_status[:accounts]&.any? %>
|
||||
<div class="text-xs text-tertiary ml-5">
|
||||
<%= t(".stale_pending_accounts", accounts: pending_status[:accounts].join(", ")) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% elsif (reconciled_status = simplefin_item.last_sync_reconciled_status)[:count] > 0 %>
|
||||
<div class="text-success flex items-center gap-1">
|
||||
<%= icon "check-circle", size: "sm", color: "success" %>
|
||||
<%= tag.span reconciled_status[:message], class: "text-sm" %>
|
||||
<span class="text-xs text-tertiary"><%= t(".reconciled_details_note") %></span>
|
||||
</div>
|
||||
<% elsif simplefin_item.rate_limited_message.present? %>
|
||||
<div class="text-warning flex items-center gap-1">
|
||||
<%= icon "clock", size: "sm", color: "warning" %>
|
||||
@@ -117,7 +136,7 @@
|
||||
<% elsif duplicate_only_errors %>
|
||||
<div class="text-secondary flex items-center gap-1">
|
||||
<%= icon "info", size: "sm" %>
|
||||
<%= tag.span "Some accounts were skipped as duplicates — use ‘Link existing accounts’ to merge.", class: "text-secondary" %>
|
||||
<%= tag.span t(".duplicate_accounts_skipped"), class: "text-secondary" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="text-secondary">
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex md:hidden items-center gap-1 col-span-2 relative">
|
||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||
<%= render "transactions/transaction_category", transaction: transaction, variant: "mobile" %>
|
||||
<% if transaction.merchant&.logo_url.present? %>
|
||||
<%= image_tag transaction.merchant.logo_url,
|
||||
class: "w-5 h-5 rounded-full absolute -bottom-1 -right-1 border border-secondary pointer-events-none",
|
||||
@@ -78,14 +78,36 @@
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Investment activity label badge %>
|
||||
<% if transaction.investment_activity_label.present? %>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-alpha-black-50 text-secondary" title="<%= t("transactions.transaction.activity_type_tooltip") %>">
|
||||
<%= t("transactions.activity_labels.#{transaction.investment_activity_label.parameterize(separator: '_')}") %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Pending indicator %>
|
||||
<% if transaction.pending? %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="Pending — may change when posted">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary text-secondary" title="<%= t("transactions.transaction.pending_tooltip") %>">
|
||||
<%= icon "clock", size: "sm", color: "current" %>
|
||||
Pending
|
||||
<%= t("transactions.transaction.pending") %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Potential duplicate indicator - different styling for low vs medium confidence %>
|
||||
<% if transaction.has_potential_duplicate? %>
|
||||
<% if transaction.low_confidence_duplicate? %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-secondary bg-surface-inset text-secondary" title="<%= t("transactions.transaction.review_recommended_tooltip") %>">
|
||||
<%= icon "help-circle", size: "sm", color: "current" %>
|
||||
<%= t("transactions.transaction.review_recommended") %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-warning bg-warning/10 text-warning" title="<%= t("transactions.transaction.potential_duplicate_tooltip") %>">
|
||||
<%= icon "alert-triangle", size: "sm", color: "current" %>
|
||||
<%= t("transactions.transaction.possible_duplicate") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if transaction.transfer.present? %>
|
||||
<%= render "transactions/transfer_match", transaction: transaction %>
|
||||
<% end %>
|
||||
@@ -122,7 +144,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 col-span-2">
|
||||
<%= render "transactions/transaction_category", transaction: transaction %>
|
||||
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop" %>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto text-right">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%# locals: (transaction:) %>
|
||||
<%# locals: (transaction:, variant:) %>
|
||||
|
||||
<div id="<%= dom_id(transaction, "category_menu") %>">
|
||||
<div id="<%= dom_id(transaction, "category_menu_#{variant}") %>">
|
||||
<% if transaction.transfer&.categorizable? || transaction.transfer.nil? %>
|
||||
<%= render "categories/menu", transaction: transaction %>
|
||||
<% else %>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user