Merge branch 'feat/retirement-v2-preview' into feat/retirement-v2-data

This commit is contained in:
Guillem Arias
2026-05-30 13:31:58 +02:00
279 changed files with 16062 additions and 1122 deletions

View File

@@ -20,6 +20,9 @@ jobs:
- name: Scan for security vulnerabilities in Ruby dependencies
run: bin/brakeman --no-pager
- name: Validate preview deploy workflow hardening
run: ruby bin/preview_deploy_security_check.rb
scan_js:
runs-on: ubuntu-latest
timeout-minutes: 10

View File

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

View File

@@ -16,6 +16,11 @@ jobs:
name: Deploy to Cloudflare Containers
runs-on: ubuntu-latest
timeout-minutes: 15
concurrency:
group: preview-deploy-${{ github.event.pull_request.number }}
cancel-in-progress: true
environment:
name: preview
permissions:
actions: read
contents: read
@@ -32,10 +37,10 @@ jobs:
steps:
- name: Wait for PR CI to pass
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const headSha = context.payload.pull_request.head.sha;
const headSha = process.env.HEAD_SHA;
const timeoutMs = 10 * 60 * 1000;
const pollMs = 15 * 1000;
const startedAt = Date.now();
@@ -73,68 +78,62 @@ jobs:
core.setFailed(`Timed out waiting for Pull Request workflow for ${headSha}. Last state: ${lastState}`);
- name: Checkout code
uses: actions/checkout@v5
- name: Checkout PR code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
path: pr
persist-credentials: false
- name: Checkout trusted preview tooling
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ github.event.pull_request.base.sha }}
path: trusted
persist-credentials: false
sparse-checkout: |
workers/preview
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: "24"
- name: Install Wrangler dependencies
working-directory: workers/preview
run: npm install
- name: Configure preview files for this PR
working-directory: workers/preview
run: |
sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" wrangler.toml
sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" src/index.ts
cat wrangler.toml
- name: Delete existing preview container app before redeploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
working-directory: workers/preview
- name: Prepare trusted preview deploy workspace
run: |
set -euo pipefail
CONTAINER_NAME="sure-preview-${PR_NUMBER}-railscontainer"
echo "Looking for stale preview container app: $CONTAINER_NAME"
CONTAINER_ID=$(npx wrangler containers list --json | jq -r --arg NAME "$CONTAINER_NAME" '
map(select((.name // .application_name // .app_name // "") == $NAME))
| first
| (.id // .container_id // .application_id // empty)
')
preview_dir="$RUNNER_TEMP/sure-preview-worker"
rm -rf "$preview_dir"
mkdir -p "$preview_dir"
if [ -n "$CONTAINER_ID" ]; then
echo "Deleting stale preview container app $CONTAINER_NAME ($CONTAINER_ID)"
npx wrangler containers delete "$CONTAINER_ID"
else
echo "No stale preview container app found; continuing"
fi
cp trusted/workers/preview/package.json "$preview_dir/package.json"
cp trusted/workers/preview/package-lock.json "$preview_dir/package-lock.json"
cp trusted/workers/preview/tsconfig.json "$preview_dir/tsconfig.json"
cp trusted/workers/preview/wrangler.toml "$preview_dir/wrangler.toml"
cp -R pr/workers/preview/src "$preview_dir/src"
- name: Delete existing preview Worker before redeploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
working-directory: workers/preview
run: |
WORKER_NAME="sure-preview-${PR_NUMBER}"
echo "Ensuring fresh preview deployment for $WORKER_NAME"
npx wrangler delete --name "$WORKER_NAME" --force || echo "Existing preview not found; continuing"
sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/wrangler.toml"
sed -i "s/\${PR_NUMBER}/${PR_NUMBER}/g" "$preview_dir/src/index.ts"
sed -i \
"s#image = \"../../Dockerfile.preview\"#image = \"${GITHUB_WORKSPACE}/pr/Dockerfile.preview\"#" \
"$preview_dir/wrangler.toml"
cat "$preview_dir/wrangler.toml"
cd "$preview_dir"
npm ci --ignore-scripts --no-audit --no-fund
- name: Create GitHub Deployment
id: deployment
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
script: |
const prNumber = process.env.PR_NUMBER;
const headSha = process.env.HEAD_SHA;
const deployment = await github.rest.repos.createDeployment({
owner: context.repo.owner,
repo: context.repo.repo,
ref: context.payload.pull_request.head.sha,
environment: `preview-pr-${process.env.PR_NUMBER}`,
ref: headSha,
environment: `preview-pr-${prNumber}`,
auto_merge: false,
required_contexts: [],
description: 'PR Preview Deployment'
@@ -144,13 +143,15 @@ jobs:
- name: Deploy to Cloudflare Containers
id: deploy
working-directory: workers/preview
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_WORKERS_SUBDOMAIN: ${{ secrets.CLOUDFLARE_WORKERS_SUBDOMAIN }}
run: |
npx wrangler deploy --var "PR_NUMBER:${PR_NUMBER}"
set -euo pipefail
cd "$RUNNER_TEMP/sure-preview-worker"
./node_modules/.bin/wrangler deploy --config wrangler.toml --var "PR_NUMBER:${PR_NUMBER}"
# Get the deployment URL
PREVIEW_URL="https://sure-preview-${PR_NUMBER}.${CLOUDFLARE_WORKERS_SUBDOMAIN}.workers.dev"
@@ -165,22 +166,26 @@ jobs:
- name: Update Deployment Status
if: always() && steps.deployment.outputs.result
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
DEPLOYMENT_ID: ${{ steps.deployment.outputs.result }}
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
with:
script: |
const state = '${{ job.status }}' === 'success' ? 'success' : 'failure';
const previewUrl = process.env.PREVIEW_URL || undefined;
await github.rest.repos.createDeploymentStatus({
owner: context.repo.owner,
repo: context.repo.repo,
deployment_id: ${{ steps.deployment.outputs.result }},
deployment_id: Number(process.env.DEPLOYMENT_ID),
state: state,
environment_url: state === 'success' ? '${{ steps.deploy.outputs.preview_url }}' : undefined,
environment_url: state === 'success' ? previewUrl : undefined,
description: state === 'success' ? 'Preview deployed successfully' : 'Preview deployment failed'
});
- name: Comment on PR
if: success()
uses: actions/github-script@v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
with:
@@ -229,9 +234,8 @@ jobs:
}
- name: Store cleanup metadata
if: success()
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: preview-cleanup-pr-${{ env.PR_NUMBER }}
path: |
workers/preview/wrangler.toml
path: ${{ runner.temp }}/sure-preview-worker/wrangler.toml
retention-days: 2

View File

@@ -4,7 +4,7 @@
<div class="flex items-center gap-1">
<%= tag.p title, class: "text-sm font-medium text-secondary" %>
<% if account.investment? %>
<% if account.supports_trades? %>
<%= render "investments/value_tooltip", balance: account.balance_money, holdings: holdings_value_money, cash: account.cash_balance_money %>
<% end %>
</div>
@@ -19,7 +19,7 @@
<%= form_with url: account_path(account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
<div class="flex items-center gap-2">
<% if account.investment? %>
<% if account.supports_trades? %>
<%= form.select :chart_view,
[[t(".views.total_value"), "balance"], [t(".views.holdings"), "holdings_balance"], [t(".views.cash"), "cash_balance"]],
{ selected: view },

View File

@@ -24,6 +24,7 @@ class AccountsController < ApplicationController
@ibkr_items = visible_provider_items(family.ibkr_items.ordered.includes(:syncs, :ibkr_accounts))
@indexa_capital_items = visible_provider_items(family.indexa_capital_items.ordered.includes(:syncs, :indexa_capital_accounts))
@sophtron_items = visible_provider_items(family.sophtron_items.ordered.includes(:syncs, :sophtron_accounts))
@binance_items = visible_provider_items(family.binance_items.ordered.includes(:binance_accounts, :accounts, :syncs))
# Build sync stats maps for all providers
build_sync_stats_maps
@@ -397,5 +398,20 @@ class AccountsController < ApplicationController
latest_sync = item.syncs.ordered.first
@indexa_capital_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
end
# Binance sync stats
@binance_sync_stats_map = {}
@binance_unlinked_count_map = {}
@binance_items.each do |item|
latest_sync = item.syncs.ordered.first
@binance_sync_stats_map[item.id] = latest_sync&.sync_stats || {}
# Count unlinked accounts
count = item.binance_accounts
.left_joins(:account_provider)
.where(account_providers: { id: nil })
.count
@binance_unlinked_count_map[item.id] = count
end
end
end

View File

@@ -211,7 +211,23 @@ class BinanceItemsController < ApplicationController
end
def complete_account_setup
selected_accounts = Array(params[:selected_accounts]).reject(&:blank?)
setup_params = complete_account_setup_params
if setup_params[:sync_start_date].present?
parsed_date = begin
Date.parse(setup_params[:sync_start_date].to_s)
rescue ArgumentError
nil
end
if parsed_date.present? && parsed_date <= Date.current
@binance_item.update!(sync_start_date: parsed_date)
else
flash.now[:alert] = "Sync start date must be a valid date in the past."
end
end
selected_accounts = Array(setup_params[:selected_accounts]).reject(&:blank?)
created_accounts = []
selected_accounts.each do |binance_account_id|
@@ -284,4 +300,8 @@ class BinanceItemsController < ApplicationController
def binance_item_params
params.require(:binance_item).permit(:name, :sync_start_date, :api_key, :api_secret)
end
def complete_account_setup_params
params.permit(:sync_start_date, selected_accounts: [])
end
end

View File

@@ -14,6 +14,12 @@ class CategoriesController < ApplicationController
set_categories
end
def merge
@categories = Current.family.categories.alphabetically
render layout: turbo_frame_request? ? false : "settings"
end
def create
@category = Current.family.categories.new(category_params)
@@ -67,6 +73,29 @@ class CategoriesController < ApplicationController
redirect_back_or_to categories_path, notice: t(".success")
end
def perform_merge
permitted_params = category_merge_params
if permitted_params[:target_id].present? && Array(permitted_params[:source_ids]).include?(permitted_params[:target_id])
return redirect_to merge_categories_path, alert: t(".target_selected_as_source")
end
target = Current.family.categories.find_by(id: permitted_params[:target_id])
return redirect_to merge_categories_path, alert: t(".target_not_found") unless target
sources = Current.family.categories.where(id: permitted_params[:source_ids])
return redirect_to merge_categories_path, alert: t(".invalid_categories") unless sources.any?
merger = Category::Merger.new(family: Current.family, target_category: target, source_categories: sources)
return redirect_to merge_categories_path, alert: t(".no_categories_selected") unless merger.merge!
redirect_to categories_path, notice: t(".success", count: merger.merged_count)
rescue Category::Merger::UnauthorizedCategoryError => e
redirect_to merge_categories_path, alert: e.message
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotDestroyed => e
redirect_to merge_categories_path, alert: record_error_message(e)
end
private
def set_category
@category = Current.family.categories.find(params[:id])
@@ -89,4 +118,13 @@ class CategoriesController < ApplicationController
def category_params
params.require(:category).permit(:name, :color, :parent_id, :lucide_icon)
end
def category_merge_params
params.permit(:target_id, source_ids: [])
end
def record_error_message(error)
record = error.respond_to?(:record) ? error.record : nil
record&.errors&.full_messages&.to_sentence.presence || error.message
end
end

View File

@@ -2,6 +2,7 @@ class ImportsController < ApplicationController
include SettingsHelper
before_action :set_import, only: %i[show update publish destroy revert apply_template]
before_action :require_statement_import_permission!, only: %i[update publish destroy revert apply_template]
def update
# Handle both pdf_import[account_id] and import[account_id] param formats
@@ -13,7 +14,9 @@ class ImportsController < ApplicationController
redirect_back_or_to import_path(@import), alert: t("imports.update.invalid_account", default: "Account not found.")
return
end
@import.update!(account: account)
return if @import.account_statement.present? && !require_account_permission!(account)
@import.is_a?(PdfImport) ? @import.assign_account!(account) : @import.update!(account: account)
end
redirect_to import_path(@import), notice: t("imports.update.account_saved", default: "Account saved.")
@@ -134,24 +137,32 @@ class ImportsController < ApplicationController
private
def set_import
@import = Current.family.imports.includes(:account).find(params[:id])
@import = Current.family.imports.includes(:account, :account_statement).find(params[:id])
raise ActiveRecord::RecordNotFound if @import.account_statement.present? && !@import.account_statement.viewable_by?(Current.user)
end
def import_params
params.require(:import).permit(:import_file)
end
def require_statement_import_permission!
return if @import.account_statement.blank? || @import.account_statement.manageable_by?(Current.user)
redirect_target = @import.account || @import.account_statement
redirect_back_or_to redirect_target, alert: t("accounts.not_authorized")
end
def create_pdf_import(file)
if file.size > Import::MAX_PDF_SIZE
redirect_to new_import_path, alert: t("imports.create.pdf_too_large", max_size: Import::MAX_PDF_SIZE / 1.megabyte)
return
end
return redirect_to new_import_path, alert: t("accounts.not_authorized") unless AccountStatement.statement_manager?(Current.user)
return redirect_to new_import_path, alert: t("imports.create.pdf_too_large", max_size: Import::MAX_PDF_SIZE / 1.megabyte) if file.size > Import::MAX_PDF_SIZE
pdf_import = Current.family.imports.create!(type: "PdfImport")
pdf_import.pdf_file.attach(file)
pdf_import = PdfImport.create_from_upload!(family: Current.family, file: file, user: Current.user)
pdf_import.process_with_ai_later
redirect_to import_path(pdf_import), notice: t("imports.create.pdf_processing")
rescue AccountStatement::DuplicateUploadError
redirect_to new_import_path, alert: t("imports.create.duplicate_pdf_unavailable")
rescue AccountStatement::InvalidUploadError
redirect_to new_import_path, alert: t("imports.create.invalid_pdf")
end
def create_document_import(file)

View File

@@ -17,7 +17,9 @@ class InvitationsController < ApplicationController
if @invitation.save
normalized_email = @invitation.email.to_s.strip.downcase
existing_user = User.find_by(email: normalized_email)
if existing_user && @invitation.accept_for(existing_user)
if existing_user && @invitation.would_orphan_owned_accounts?(existing_user)
flash[:alert] = t(".existing_user_has_family_data")
elsif existing_user && @invitation.accept_for(existing_user)
flash[:notice] = t(".existing_user_added")
elsif existing_user
flash[:alert] = t(".failure")

View File

@@ -1,4 +1,6 @@
class PlaidItemsController < ApplicationController
include StreamExtensions
before_action :set_plaid_item, only: %i[edit destroy sync]
before_action :require_admin!, only: %i[new create select_existing_account link_existing_account edit destroy sync]
@@ -12,6 +14,8 @@ class PlaidItemsController < ApplicationController
accountable_type: params[:accountable_type] || "Depository",
region: region
)
rescue Plaid::ApiError => e
handle_link_token_error(e)
end
def edit
@@ -21,6 +25,8 @@ class PlaidItemsController < ApplicationController
webhooks_url: webhooks_url,
redirect_url: accounts_url,
)
rescue Plaid::ApiError => e
handle_link_token_error(e)
end
def create
@@ -104,6 +110,58 @@ class PlaidItemsController < ApplicationController
plaid_item_params.dig(:metadata, :institution, :name)
end
# When `link_token/create` (or the update equivalent) raises, surface a
# friendly alert to the user instead of letting the modal frame render
# blank. Plaid configuration/product-access errors are the common case for
# self-hosted users — without this, the Link modal simply never opens and
# the only signal lives in server logs.
def handle_link_token_error(error)
error_body = safe_parse_plaid_error(error)
error_code = error_body["error_code"].to_s
Rails.logger.warn(
"Plaid link_token request failed: #{error_code} - #{error_body['error_message']}"
)
Sentry.capture_exception(error) if defined?(Sentry)
alert = friendly_link_token_alert(error_code, error_body["error_message"])
respond_to do |format|
format.html { redirect_to accounts_path, alert: alert }
format.turbo_stream { stream_redirect_to(accounts_path, alert: alert) }
end
end
def safe_parse_plaid_error(error)
JSON.parse(error.response_body.to_s)
rescue JSON::ParserError
{}
end
# Plaid surfaces its own actionable copy on configuration / product-access
# failures (e.g. "Your account is not enabled for the following products
# [...]. To request access, visit dashboard.plaid.com..."). Those messages
# are safe to show verbatim — they describe a Plaid-side config issue,
# not user data. For everything else we fall back to a generic message
# and rely on the log + Sentry trail.
SHOWABLE_PLAID_ERROR_CODES = %w[
INVALID_PRODUCT
PRODUCTS_NOT_SUPPORTED
NO_PRODUCTS_PERMISSION
ADDITION_LIMIT
INVALID_INSTITUTION
INSTITUTION_NOT_ENABLED_IN_REGION
INSTITUTION_NOT_SUPPORTED
].freeze
def friendly_link_token_alert(error_code, error_message)
if SHOWABLE_PLAID_ERROR_CODES.include?(error_code) && error_message.present?
t("plaid_items.errors.link_token_with_message", message: error_message)
else
t("plaid_items.errors.link_token_generic")
end
end
def plaid_us_webhooks_url
return webhooks_plaid_url if Rails.env.production?

View File

@@ -471,6 +471,7 @@ class ReportsController < ApplicationController
has_investments: true,
portfolio_value: investment_statement.portfolio_value_money,
unrealized_trend: investment_statement.unrealized_gains_trend,
period_return_trend: investment_statement.period_return_trend(period: @period),
period_contributions: period_totals.contributions,
period_withdrawals: period_totals.withdrawals,
top_holdings: investment_statement.top_holdings(limit: 5),

View File

@@ -26,6 +26,12 @@ class Settings::ProfilesController < ApplicationController
return
end
if @user.owned_accounts.where.not(family_id: Current.family.id).exists?
flash[:alert] = t(".member_owns_other_family_data")
redirect_to settings_profile_path
return
end
if @user.destroy
# Also destroy the invitation associated with this user for this family
Current.family.invitations.find_by(email: @user.email)&.destroy

View File

@@ -168,7 +168,8 @@ module LanguagesHelper
"zh-CN", # Chinese (Simplified)
"zh-TW", # Chinese (Traditional)
"nl", # Dutch
"hu" # Hungarian
"hu", # Hungarian
"vi" # Vietnamese
].freeze
COUNTRY_MAPPING = {

View File

@@ -1,10 +1,11 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["messages", "form", "input"];
static targets = ["messages", "form", "input", "submit"];
connect() {
this.#configureAutoScroll();
this.#updateSubmitState();
}
disconnect() {
@@ -22,10 +23,13 @@ export default class extends Controller {
input.style.height = `${Math.min(input.scrollHeight, lineHeight * maxLines)}px`;
input.style.overflowY =
input.scrollHeight > lineHeight * maxLines ? "auto" : "hidden";
this.#updateSubmitState();
}
submitSampleQuestion(e) {
this.inputTarget.value = e.target.dataset.chatQuestionParam;
this.#updateSubmitState();
setTimeout(() => {
this.formTarget.requestSubmit();
@@ -36,10 +40,21 @@ export default class extends Controller {
handleInputKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.formTarget.requestSubmit();
if (this.#hasContent()) {
this.formTarget.requestSubmit();
}
}
}
#hasContent() {
return this.inputTarget.value.trim().length > 0;
}
#updateSubmitState() {
if (!this.hasSubmitTarget) return;
this.submitTarget.disabled = !this.#hasContent();
}
#configureAutoScroll() {
this.messagesObserver = new MutationObserver((_mutations) => {
if (this.hasMessagesTarget) {

View File

@@ -128,9 +128,32 @@ export default class extends Controller {
};
handleExit = (err, metadata) => {
// If there was an error during update mode, refresh the page to show latest status
if (err && metadata.status === "requires_credentials") {
// If there was an error during update mode, refresh the page to show
// latest status. Guard `metadata` (Plaid can fire onExit with it
// undefined when Link aborts very early) and gate the redirect on
// `isUpdateValue` so first-time link failures don't bounce the user
// away from whatever page they were on.
if (
err &&
metadata &&
metadata.status === "requires_credentials" &&
this.isUpdateValue
) {
window.location.href = "/accounts";
return;
}
// Promote Plaid's own error payload to the console so a silent modal
// close still leaves a breadcrumb (issue #1792). Plaid Link's own UI
// is responsible for showing a message inside the modal when this
// fires; backend link-token failures are handled server-side via the
// PlaidItemsController rescue + flash.
if (err?.error_code) {
console.error(
"Plaid Link exited with error",
err.error_code,
err.display_message || err.error_message
);
}
};

View File

@@ -511,7 +511,7 @@ export default class extends Controller {
.append("div")
.attr(
"class",
"bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50 top-0",
"bg-container text-primary text-sm font-sans p-2 border border-secondary rounded-lg pointer-events-none absolute z-50 top-0 privacy-sensitive",
)
.style("opacity", 0)
.style("pointer-events", "none");

View File

@@ -42,21 +42,13 @@ class IdentifyRecurringTransactionsJob < ApplicationJob
"recurring_transaction_identify:#{family_id}"
end
# Debounce gate: delegate to `Sync.any_incomplete_for?`, which polls every
# `Syncable` provider association on `Family` via reflection. The previous
# hand-rolled list covered only 5 of the 14 `*_items` associations on
# `Family`, so a Coinbase/Mercury/Brex/etc. sync in flight silently
# bypassed this gate and let the identifier run against a partial dataset.
def family_has_incomplete_syncs?(family)
# Check family's own syncs
return true if family.syncs.incomplete.exists?
# Check all provider items' syncs
return true if family.plaid_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:plaid_items)
return true if family.simplefin_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:simplefin_items)
return true if family.lunchflow_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:lunchflow_items)
return true if family.enable_banking_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:enable_banking_items)
return true if family.sophtron_items.joins(:syncs).merge(Sync.incomplete).exists? if family.respond_to?(:sophtron_items)
# Check accounts' syncs
return true if family.accounts.joins(:syncs).merge(Sync.incomplete).exists?
false
Sync.any_incomplete_for?(family)
end
def with_advisory_lock(family_id)

View File

@@ -3,9 +3,9 @@ class ProcessPdfJob < ApplicationJob
def perform(pdf_import)
return unless pdf_import.is_a?(PdfImport)
return unless pdf_import.pdf_uploaded?
return reset_processing_claim(pdf_import) unless pdf_import.pdf_uploaded?
return if pdf_import.status == "complete"
return if pdf_import.ai_processed? && (!pdf_import.statement_with_transactions? || pdf_import.rows_count > 0)
return reset_processing_claim(pdf_import) if pdf_import.ai_processed? && (!pdf_import.statement_with_transactions? || pdf_import.rows_count > 0)
pdf_import.update!(status: :importing)
@@ -62,12 +62,11 @@ class ProcessPdfJob < ApplicationJob
end
def upload_to_vector_store(pdf_import, document_type:)
filename = pdf_import.pdf_file.filename.to_s
file_content = pdf_import.pdf_file_content
family_document = pdf_import.family.upload_document(
file_content: file_content,
filename: filename,
filename: pdf_import.pdf_filename,
metadata: { "type" => document_type }
)
@@ -85,4 +84,8 @@ class ProcessPdfJob < ApplicationJob
def statement_with_transactions?(document_type)
document_type.in?(%w[bank_statement credit_card_statement])
end
def reset_processing_claim(pdf_import)
pdf_import.with_lock { pdf_import.update!(status: :pending) if pdf_import.importing? && pdf_import.updated_at <= 30.minutes.ago }
end
end

View File

@@ -266,7 +266,9 @@ class Account < ApplicationRecord
end
def create_from_binance_account(binance_account)
create_from_crypto_exchange_account(binance_account, family: binance_account.binance_item.family)
account = create_from_crypto_exchange_account(binance_account, family: binance_account.binance_item.family)
account.set_opening_anchor_balance(balance: 0)
account
end
def create_from_ibkr_account(ibkr_account)
@@ -289,6 +291,7 @@ class Account < ApplicationRecord
}
}
# Capture the created account in a variable
create_and_sync(attributes, skip_initial_sync: true)
end

View File

@@ -44,12 +44,37 @@ class Account::ProviderImportAdapter
raise ArgumentError, "Entry with external_id '#{external_id}' already exists with different entryable type: #{entry.entryable_type}"
end
# Determine early whether the incoming transaction is pending — needed by both
# the protection check (pending→booked bypass) and the auto-claim path below.
incoming_pending = false
if extra.is_a?(Hash)
pending_extra = extra.with_indifferent_access
incoming_pending =
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("enable_banking", "pending"))
end
# === PROTECTION CHECK: Skip entries that should not be overwritten ===
# Check persisted Transaction entries for protection flags before making changes.
# This prevents sync from overwriting user edits, CSV imports, or excluded entries.
if entry.persisted?
skip_reason = determine_skip_reason(entry)
if skip_reason
# Pending→booked bypass for user_modified entries: clear the stale pending flag
# when the provider delivers a booked version of the same transaction.
# Some ASPSPs (e.g. Revolut Italy via Enable Banking) reuse the same transaction_id
# for pending and booked, so the entry is found by external_id rather than going
# through the auto-claim path. Without this, a user who categorised a pending entry
# (setting user_modified=true) would see the pending badge stuck forever.
# Excluded and import_locked entries are intentionally left untouched.
if skip_reason == "user_modified" && !incoming_pending && entry.entryable.is_a?(Transaction)
entry_is_pending = Transaction::PENDING_PROVIDERS.any? { |p| entry.transaction.extra&.dig(p, "pending") }
if entry_is_pending
entry.transaction.update!(extra: clear_pending_flags_from_extra(entry.transaction.extra))
end
end
record_skip(entry, skip_reason)
return entry
end
@@ -76,17 +101,6 @@ class Account::ProviderImportAdapter
end
end
# If still a new entry and this is a POSTED transaction, check for matching pending transactions
incoming_pending = false
if extra.is_a?(Hash)
pending_extra = extra.with_indifferent_access
incoming_pending =
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("simplefin", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("plaid", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("lunchflow", "pending")) ||
ActiveModel::Type::Boolean.new.cast(pending_extra.dig("enable_banking", "pending"))
end
if entry.new_record? && !incoming_pending
pending_match = nil
@@ -119,12 +133,7 @@ class Account::ProviderImportAdapter
# exclude it from re-import (preventing the old pending from being recreated on the
# next sync when the stored raw payload still contains the pending transaction data).
if entry.entryable.is_a?(Transaction)
ex = (entry.transaction.extra || {}).deep_dup
Transaction::PENDING_PROVIDERS.each do |provider|
next unless ex.key?(provider)
ex[provider].delete("pending")
ex.delete(provider) if ex[provider].empty?
end
ex = clear_pending_flags_from_extra(entry.transaction.extra)
if old_pending_external_id.present?
existing_claims = Array.wrap(ex["auto_claimed_pending_ids"])
ex["auto_claimed_pending_ids"] = (existing_claims + [ old_pending_external_id ]).uniq
@@ -134,6 +143,18 @@ class Account::ProviderImportAdapter
end
end
# Pending→booked for same-external-id providers (non-protected path).
# For ASPSPs like Revolut Italy that reuse the same transaction_id for pending and
# booked, the auto-claim path above is skipped (entry.persisted? from the start).
# If extra is nil (no FX, no MCC) the deep-merge block later is skipped too, so we
# must clear the stale pending flag here before the final save.
# (The auto-claim path already clears it in-memory, so this is a no-op there.)
if !incoming_pending && entry.entryable.is_a?(Transaction)
if Transaction::PENDING_PROVIDERS.any? { |p| entry.transaction.extra&.dig(p, "pending") }
entry.transaction.extra = clear_pending_flags_from_extra(entry.transaction.extra)
end
end
# Track if this is a new posted transaction (for fuzzy suggestion after save)
is_new_posted = entry.new_record? && !incoming_pending
@@ -977,11 +998,25 @@ class Account::ProviderImportAdapter
}
end
# Memoized per adapter instance (which is per-account). Membership in
# goal_accounts is stable across a sync batch.
def account_linked_to_any_goal?
return @account_linked_to_any_goal if defined?(@account_linked_to_any_goal)
private
@account_linked_to_any_goal = account.goal_accounts.exists?
end
# Memoized per adapter instance (which is per-account). Membership in
# goal_accounts is stable across a sync batch.
def account_linked_to_any_goal?
return @account_linked_to_any_goal if defined?(@account_linked_to_any_goal)
@account_linked_to_any_goal = account.goal_accounts.exists?
end
def clear_pending_flags_from_extra(extra)
ex = (extra || {}).deep_dup
ex = {} unless ex.is_a?(Hash)
Transaction::PENDING_PROVIDERS.each do |provider|
next unless ex.key?(provider)
next unless ex[provider].is_a?(Hash)
ex[provider].delete("pending")
ex.delete(provider) if ex[provider].empty?
end
ex
end
end

View File

@@ -33,6 +33,7 @@ class AccountStatement < ApplicationRecord
belongs_to :account, optional: true
belongs_to :suggested_account, class_name: "Account", optional: true
has_many :pdf_imports, -> { where(type: "PdfImport").ordered }, class_name: "PdfImport", dependent: :restrict_with_error
has_one_attached :original_file, dependent: :purge_later
enum :source, { manual_upload: "manual_upload" }, validate: true, default: "manual_upload"
@@ -360,6 +361,10 @@ class AccountStatement < ApplicationRecord
content_type.in?(ALLOWED_EXTENSION_CONTENT_TYPES[".xlsx"])
end
def latest_reusable_pdf_import
pdf_imports.where.not(status: :failed).order(created_at: :desc).first
end
private
def reconciliation_check(key:, statement_amount:, ledger_amount:)

View File

@@ -27,6 +27,7 @@ module Assistant
Function::GetHoldings,
Function::GetBalanceSheet,
Function::GetIncomeStatement,
Function::GetBudget,
Function::ImportBankStatement,
Function::SearchFamilyFiles,
Function::CreateGoal

View File

@@ -0,0 +1,200 @@
class Assistant::Function::GetBudget < Assistant::Function
include ActiveSupport::NumberHelper
MAX_PRIOR_MONTHS = 11
class << self
def name
"get_budget"
end
def description
<<~INSTRUCTIONS
Use this to see how the user is tracking against their monthly budget total
budgeted vs spent and a parent/subcategory breakdown matching the budget UI.
This is great for answering questions like:
- How am I tracking against my budget this month?
- Which categories am I over budget on?
- How does this month's spending compare to the last few months?
Parameters:
- `month` (optional): "YYYY-MM" or "MMM-YYYY". Defaults to the current month.
- `prior_months` (optional): integer 0..#{MAX_PRIOR_MONTHS}. Number of months
preceding the target month to include for trend comparison. Default 0.
Example (current month only):
```
get_budget({})
```
Example (current month plus last 2 months):
```
get_budget({ month: "#{Date.current.strftime('%Y-%m')}", prior_months: 2 })
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
properties: {
month: {
type: "string",
description: "Target month in YYYY-MM or MMM-YYYY format. Defaults to the current month."
},
prior_months: {
type: "integer",
description: "Number of months before the target month to also return for trend comparison.",
minimum: 0,
maximum: MAX_PRIOR_MONTHS
}
}
)
end
def call(params = {})
target_start = resolve_month_start(params["month"])
prior = [ params["prior_months"].to_i, 0 ].max
prior = [ prior, MAX_PRIOR_MONTHS ].min
month_starts = (0..prior).map { |offset| shift_months(target_start, -offset) }.reverse
requested = month_starts.count { |start_date| Budget.budget_date_valid?(start_date, family: family) }
months = month_starts.filter_map do |start_date|
next unless Budget.budget_date_valid?(start_date, family: family)
build_month_payload(start_date, bootstrap: start_date == target_start)
end
result = {
currency: family.currency,
months: months
}
unavailable = requested - months.length
result[:months_unavailable] = unavailable if unavailable > 0
result
end
private
def build_month_payload(start_date, bootstrap:)
budget = if bootstrap
Budget.find_or_bootstrap(family, start_date: start_date, user: user)
else
budget_start, budget_end = Budget.period_for(start_date, family: family)
family.budgets.find_by(start_date: budget_start, end_date: budget_end)
end
return nil unless budget
groups = BudgetCategory::Group.for(budget.budget_categories)
{
month: budget.to_param,
period: {
start_date: budget.start_date,
end_date: budget.end_date
},
is_current: budget.current?,
initialized: budget.initialized?,
totals: {
budgeted_spending: format_money(budget.budgeted_spending),
allocated_spending: format_money(budget.allocated_spending),
available_to_allocate: format_money(budget.available_to_allocate),
actual_spending: format_money(budget.actual_spending),
available_to_spend: format_money(budget.available_to_spend),
percent_of_budget_spent: format_percent(budget.initialized? ? budget.percent_of_budget_spent : 0),
overage_percent: format_percent(budget.overage_percent)
},
income: {
expected_income: format_money(budget.expected_income),
actual_income: format_money(budget.actual_income),
remaining_expected_income: format_money((budget.expected_income || 0) - budget.actual_income)
},
categories: groups.map { |group| serialize_group(group, include_daily_suggestion: budget.current?) }
}
end
def serialize_group(group, include_daily_suggestion:)
parent = group.budget_category
serialize_category(parent, include_daily_suggestion: include_daily_suggestion).merge(
color: parent.category.color,
subcategories: group.budget_subcategories.map do |sub|
serialize_category(sub, include_daily_suggestion: include_daily_suggestion).merge(
inherits_parent_budget: sub.inherits_parent_budget?
)
end
)
end
def serialize_category(bc, include_daily_suggestion:)
payload = {
name: bc.name,
budgeted: format_money(bc.display_budgeted_spending),
actual: format_money(bc.actual_spending),
available: format_money(bc.available_to_spend),
percent_spent: format_percent(bc.percent_of_budget_spent || 0),
status: category_status(bc)
}
if include_daily_suggestion
suggestion = bc.suggested_daily_spending
payload[:suggested_daily_spending] = suggestion[:amount].format if suggestion
end
payload
end
def category_status(bc)
return "over_budget" if bc.over_budget_with_budget?
return "unbudgeted" if bc.unbudgeted_with_spending?
return "near_limit" if bc.budgeted? && bc.near_limit?
return "on_track" if bc.on_track?
"no_activity"
end
def resolve_month_start(raw)
base = parse_month(raw)
return (base || Date.current).beginning_of_month unless family.uses_custom_month_start?
# Match Budget.param_to_date for explicit slugs so the input round-trips with the response.
base ? Date.new(base.year, base.month, family.month_start_day) : family.custom_month_start_for(Date.current)
end
def parse_month(raw)
return nil if raw.blank?
# Date.strptime ignores trailing characters, so guard with strict anchors first.
fmt = case raw
when /\A\d{4}-\d{2}\z/ then "%Y-%m"
when /\A[A-Za-z]{3}-\d{4}\z/ then "%b-%Y"
end
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY." if fmt.nil?
Date.strptime(raw, fmt)
rescue ArgumentError
raise Assistant::Error, "Invalid month: #{raw}. Use YYYY-MM or MMM-YYYY."
end
def shift_months(date, n)
shifted = date >> n
if family.uses_custom_month_start?
family.custom_month_start_for(shifted)
else
shifted.beginning_of_month
end
end
def format_money(value)
Money.new(value || 0, family.currency).format
end
def format_percent(value)
number_to_percentage(value || 0, precision: 1)
end
end

View File

@@ -25,20 +25,20 @@ class Balance::ReverseCalculator < Balance::BaseCalculator
start_non_cash_balance = end_non_cash_balance
market_value_change = 0
elsif valuation && valuation.entryable.reconciliation?
# Reconciliation waypoint: reset to the known API-reported balance.
# These waypoints are created by CurrentBalanceManager when it preserves
# a stale current_anchor as a reconciliation before replacing it.
# We derive both cash and non-cash from the total to ensure the split
# reflects the account's cash ratio on that date.
# Reconciliation waypoint: hard-reset the END-of-day balance to the
# API-reported value, neutralizing any drift accumulated from missing
# transactions between here and the next anchor. The START is still
# derived from this day's own flows, so a same-day transaction is
# attributed exactly once (and not added on top of the waypoint).
end_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: valuation.amount,
date: date
)
end_non_cash_balance = valuation.amount - end_cash_balance
start_cash_balance = end_cash_balance
start_non_cash_balance = end_non_cash_balance
market_value_change = 0
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
market_value_change = market_value_change_on_date(date, flows)
else
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)

View File

@@ -61,47 +61,83 @@ class BinanceAccount::Processor
provider = binance_account.binance_item&.binance_provider
return unless provider
# 1. Initialize data from existing payload
existing_spot = binance_account.raw_transactions_payload&.dig("spot") || {}
existing_futures = binance_account.raw_transactions_payload&.dig("futures") || {}
existing_p2p = binance_account.raw_transactions_payload&.dig("p2p") || []
# 2. Fetch P2P Trades (This now runs even if you have no spot assets)
new_p2p = fetch_new_p2p_trades(provider, existing_p2p)
# 3. Handle Spot & Futures symbols
symbols = extract_trade_symbols
return if symbols.empty?
existing_spot = binance_account.raw_transactions_payload&.dig("spot") || {}
new_trades_by_symbol = {}
new_futures_by_symbol = {}
symbols.each do |symbol|
TRADE_QUOTE_CURRENCIES.each do |quote|
pair = "#{symbol}#{quote}"
begin
new_trades = fetch_new_trades(provider, pair, existing_spot[pair])
new_trades_by_symbol[pair] = new_trades if new_trades.present?
rescue Provider::Binance::InvalidSymbolError => e
# Pair doesn't exist on Binance for this quote currency — expected, skip silently
Rails.logger.debug "BinanceAccount::Processor - skipping #{pair}: #{e.message}"
# Only attempt to loop if we actually have symbols (e.g., BTC, ETH)
if symbols.any?
symbols.each do |symbol|
TRADE_QUOTE_CURRENCIES.each do |quote|
pair = "#{symbol}#{quote}"
begin
new_trades = fetch_new_trades(provider, pair, existing_spot[pair], :spot)
new_trades_by_symbol[pair] = new_trades if new_trades.present?
rescue Provider::Binance::InvalidSymbolError => e
Rails.logger.debug "BinanceAccount::Processor - skipping spot #{pair}: #{e.message}"
end
begin
new_futures = fetch_new_trades(provider, pair, existing_futures[pair], :futures)
new_futures_by_symbol[pair] = new_futures if new_futures.present?
rescue Provider::Binance::InvalidSymbolError => e
Rails.logger.debug "BinanceAccount::Processor - skipping futures #{pair}: #{e.message}"
end
end
# ApiError, AuthenticationError and RateLimitError propagate so the sync is marked failed
end
end
merged_spot = existing_spot.merge(new_trades_by_symbol) { |_pair, old, new_t| old + new_t }
# 4. Process New Records into Database Entries FIRST
# We process these into the DB first. If they fail or raise an error,
# the method halts before updating the raw_transactions_payload cache,
# ensuring a retry happens on the next sync execution.
process_trades(new_trades_by_symbol, :spot) if new_trades_by_symbol.any?
process_trades(new_futures_by_symbol, :futures) if new_futures_by_symbol.any?
process_p2p_trades(new_p2p) if new_p2p.any?
# 5. Merge Results ONLY after successful DB insertion
merged_spot = existing_spot.merge(new_trades_by_symbol) { |_pair, old, new_t| old + new_t }
merged_futures = existing_futures.merge(new_futures_by_symbol) { |_pair, old, new_t| old + new_t }
merged_p2p = existing_p2p + new_p2p
# 6. Update the Account Payload LAST (Safe Caching Boundary)
binance_account.update!(raw_transactions_payload: {
"spot" => merged_spot,
"futures" => merged_futures,
"p2p" => merged_p2p,
"fetched_at" => Time.current.iso8601
})
process_trades(new_trades_by_symbol)
end
# Fetches only trades newer than what is already cached for the given pair.
# On the first sync (no cached trades) fetches the most recent page.
# On subsequent syncs starts from max_cached_id + 1 and paginates forward.
def fetch_new_trades(provider, pair, cached_trades)
def fetch_new_trades(provider, pair, cached_trades, market_type)
limit = 1000
max_cached_id = cached_trades&.map { |t| t["id"].to_i }&.max
from_id = max_cached_id ? max_cached_id + 1 : nil
start_time = nil
unless max_cached_id
start_time = binance_account.binance_item&.sync_start_date&.to_time&.to_i&.*(1000)
end
all_new = []
loop do
page = provider.get_spot_trades(pair, limit: limit, from_id: from_id)
page = if market_type == :spot
provider.get_spot_trades(pair, limit: limit, from_id: from_id, startTime: start_time)
else
provider.get_futures_trades(pair, limit: limit, from_id: from_id, startTime: start_time)
end
break if page.blank?
all_new.concat(page)
@@ -113,6 +149,47 @@ class BinanceAccount::Processor
all_new
end
def fetch_new_p2p_trades(provider, cached_p2p)
# Binance P2P history endpoint only supports max 30-day windows.
# If no cache exists, we fetch back to sync_start_date (or default 30 days).
# If cache exists, we fetch from the last cached trade timestamp.
max_cached_timestamp = cached_p2p&.map { |t| t["createTime"].to_i }&.max
start_time = if max_cached_timestamp
max_cached_timestamp
elsif binance_account.binance_item&.sync_start_date
binance_account.binance_item.sync_start_date.to_time.to_i * 1000
else
(Time.current - 30.days).to_i * 1000
end
all_new = []
current_start = start_time
loop do
current_end = [ current_start + 30.days.to_i * 1000, Time.current.to_i * 1000 ].min
page = provider.get_all_p2p_trades(start_timestamp: current_start, end_timestamp: current_end)
# We might fetch overlapping trades if they share the exact timestamp, filter by unique orderNumber
if page.present?
cached_order_numbers = cached_p2p&.map { |t| t["orderNumber"] } || []
new_order_numbers = all_new.map { |t| t["orderNumber"] }
unique_page = page.reject do |t|
cached_order_numbers.include?(t["orderNumber"]) || new_order_numbers.include?(t["orderNumber"])
end
all_new.concat(unique_page)
end
break if current_end >= Time.current.to_i * 1000
current_start = current_end + 1
end
all_new
end
def extract_trade_symbols
stablecoins = BinanceAccount::STABLECOINS
quote_re = /(#{TRADE_QUOTE_CURRENCIES.join("|")})$/
@@ -122,21 +199,24 @@ class BinanceAccount::Processor
current = assets.map { |a| a["symbol"] || a[:symbol] }.compact
# Base symbols from previously fetched pairs (recovers sold-out assets)
prev_pairs = binance_account.raw_transactions_payload&.dig("spot")&.keys || []
prev_spot = binance_account.raw_transactions_payload&.dig("spot")&.keys || []
prev_futures = binance_account.raw_transactions_payload&.dig("futures")&.keys || []
prev_pairs = (prev_spot + prev_futures).uniq
previous = prev_pairs.map { |pair| pair.gsub(quote_re, "") }
(current + previous).uniq.compact.reject { |s| s.blank? || stablecoins.include?(s) }
end
def process_trades(trades_by_symbol)
def process_trades(trades_by_symbol, market_type)
trades_by_symbol.each do |pair, trades|
trades.each { |trade| process_spot_trade(trade, pair) }
trades.each { |trade| process_trade(trade, pair, market_type) }
end
rescue StandardError => e
Rails.logger.error "BinanceAccount::Processor - trade processing failed: #{e.message}"
raise
end
def process_spot_trade(trade, pair)
def process_trade(trade, pair, market_type)
account = binance_account.current_account
return unless account
@@ -149,7 +229,8 @@ class BinanceAccount::Processor
return unless security
external_id = "binance_spot_#{pair}_#{trade["id"]}"
prefix = market_type == :spot ? "spot" : "futures"
external_id = "binance_#{prefix}_#{pair}_#{trade["id"]}"
return if account.entries.exists?(external_id: external_id)
date = Time.zone.at(trade["time"].to_i / 1000).to_date
@@ -170,7 +251,7 @@ class BinanceAccount::Processor
amount_usd = amount_usd_raw.round(2)
commission = commission_in_usd(trade, base_symbol, price_usd, date: date)
is_buyer = trade["isBuyer"]
is_buyer = trade.key?("isBuyer") ? trade["isBuyer"] : trade["buyer"]
if is_buyer
account.entries.create!(
@@ -209,23 +290,38 @@ class BinanceAccount::Processor
end
rescue StandardError => e
Rails.logger.error "BinanceAccount::Processor - failed to process trade #{trade["id"]}: #{e.message}"
raise
end
# Converts an amount denominated in quote_symbol to USD.
# Stablecoins are treated as 1:1; others use historical price when date is given,
# falling back to current USDT spot price.
# Stablecoins are treated as 1:1.
# For fiat/crypto assets, tries Binance historical price first, falls back to internal ExchangeRate.
def quote_to_usd(amount, quote_symbol, date: nil)
return amount if BinanceAccount::STABLECOINS.include?(quote_symbol)
return amount if quote_symbol.to_s.upcase == "USD"
provider = binance_account.binance_item&.binance_provider
return nil unless provider
spot = nil
spot = provider.get_historical_price("#{quote_symbol}USDT", date) if date.present? && provider.respond_to?(:get_historical_price)
spot ||= provider.get_spot_price("#{quote_symbol}USDT")
return nil if spot.nil?
if provider
spot = nil
begin
spot = provider.get_historical_price("#{quote_symbol}USDT", date) if date.present? && provider.respond_to?(:get_historical_price)
spot ||= provider.get_spot_price("#{quote_symbol}USDT")
rescue Provider::Binance::InvalidSymbolError
# Fall through to ExchangeRate lookup
end
return (amount * spot.to_d).round(8) if spot.present?
end
(amount * spot.to_d).round(8)
# Fallback to internal app ExchangeRate provider (crucial for P2P fiat currencies like TZS, NGN)
fallback_rate = ExchangeRate.find_or_fetch_rate(from: quote_symbol, to: "USD", date: date || Date.current, cache: true)
if fallback_rate.present?
# Extract the numeric rate from the returned object (or use it directly if it's already a number)
rate_val = fallback_rate.respond_to?(:rate) ? fallback_rate.rate : fallback_rate
return (amount * rate_val.to_d).round(8)
end
nil
rescue StandardError => e
Rails.logger.warn "BinanceAccount::Processor - could not convert #{quote_symbol} to USD: #{e.message}"
nil
@@ -233,6 +329,117 @@ class BinanceAccount::Processor
# Converts the trade commission to USD.
# commissionAsset can be: a stablecoin (≈ 1 USD), the base asset, or something else (e.g. BNB).
def process_p2p_trades(trades)
account = binance_account.current_account
return unless account
Rails.logger.info "BinanceAccount::Processor - found #{trades.size} P2P trades to process"
trades.each do |trade|
external_id = "binance_p2p_#{trade["orderNumber"]}"
funding_external_id = "#{external_id}_funding"
# Deduplicate by checking for either the Trade or Funding leg in a single query
if account.entries.where(external_id: [ external_id, funding_external_id ]).exists?
Rails.logger.info "BinanceAccount::Processor - skipping P2P trade #{trade["orderNumber"]}: already exists in DB"
next
end
date = Time.zone.at(trade["createTime"].to_i / 1000).to_date
trade_type = trade["tradeType"] # BUY or SELL
begin
# Grab the exact Fiat and Crypto truth straight from the payload
fiat_currency = trade["fiat"]
fiat_amount = trade["totalPrice"].to_d
fiat_price = trade["unitPrice"].to_d
crypto_asset = trade["asset"]
gross_crypto = trade["amount"].to_d
net_crypto = (trade["takerAmount"] || gross_crypto).to_d
crypto_fee = (trade["takerCommission"] || 0).to_d
ticker = "CRYPTO:#{crypto_asset}"
security = BinanceAccount::SecurityResolver.resolve(ticker, crypto_asset)
unless security
Rails.logger.warn "BinanceAccount::Processor - skipping P2P trade #{trade["orderNumber"]}: could not resolve security for #{crypto_asset}"
next
end
# Convert the crypto fee (if any) to its fiat equivalent using the trade's exact unit price
fiat_fee = (crypto_fee * fiat_price).round(2)
# 3. AI Fix: Wrap the double-entry in a transaction block to guarantee ledger integrity
account.transaction do
if trade_type == "BUY"
# BUY LOGIC: User sent Fiat from their bank, received Crypto
account.entries.create!(
date: date,
name: "P2P Payment (#{fiat_currency})",
amount: -fiat_amount, # Fiat leaving the system
currency: fiat_currency,
external_id: funding_external_id,
source: "binance",
entryable: Transaction.new
)
account.entries.create!(
date: date,
name: "P2P Buy #{gross_crypto.round(8)} #{crypto_asset}",
amount: fiat_amount, # Fiat value entering as Crypto (Cost Basis)
currency: fiat_currency,
external_id: external_id,
source: "binance",
entryable: Trade.new(
security: security,
qty: net_crypto,
price: fiat_price,
currency: fiat_currency,
fee: fiat_fee,
investment_activity_label: "Buy"
)
)
else
# SELL LOGIC: User liquidated Crypto, received Fiat to their bank
account.entries.create!(
date: date,
name: "P2P Sell #{gross_crypto.round(8)} #{crypto_asset}",
amount: -fiat_amount, # Fiat value of Crypto leaving
currency: fiat_currency,
external_id: external_id,
source: "binance",
entryable: Trade.new(
security: security,
qty: -net_crypto,
price: fiat_price,
currency: fiat_currency,
fee: fiat_fee,
investment_activity_label: "Sell"
)
)
account.entries.create!(
date: date,
name: "P2P Receipt (#{fiat_currency})",
amount: fiat_amount, # Fiat entering the system
currency: fiat_currency,
external_id: funding_external_id,
source: "binance",
entryable: Transaction.new
)
end
end
rescue => e
Rails.logger.error "BINANCE P2P SYNC CRASHED for Order #{trade["orderNumber"]}: #{e.message}"
raise
end
end
rescue StandardError => e
Rails.logger.error "BinanceAccount::Processor - P2P trade processing failed: #{e.message}"
raise
end
def commission_in_usd(trade, base_symbol, trade_price, date: nil)
raw = trade["commission"].to_d
commission_asset = trade["commissionAsset"].to_s.upcase

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
# Pulls USDⓈ-M futures account data (balance and positions).
# Returns normalized asset list with source tag "futures".
class BinanceItem::FuturesImporter
attr_reader :binance_item, :provider
def initialize(binance_item, provider:)
@binance_item = binance_item
@provider = provider
end
# @return [Hash] { assets: [...], raw: <api_response>, source: "futures" }
def import
raw = provider.get_futures_account
# Binance Futures returns a slightly different format than spot
# assets are in raw["assets"], positions in raw["positions"]
assets = []
# Process base assets (e.g. USDT, BUSD balances)
Array(raw["assets"]).each do |asset|
wallet_balance = asset["walletBalance"].to_d
unrealized_profit = asset["unrealizedProfit"].to_d
# Total equity is wallet balance + unrealized PNL
total = wallet_balance + unrealized_profit
next if total.zero?
assets << {
symbol: asset["asset"],
free: asset["availableBalance"] || wallet_balance.to_s,
locked: (wallet_balance - (asset["availableBalance"] || wallet_balance.to_s).to_d).to_s,
total: total.to_s
}
end
{ assets: assets, raw: raw, source: "futures" }
rescue => e
Rails.logger.error "BinanceItem::FuturesImporter #{binance_item.id} - #{e.message}"
{ assets: [], raw: nil, source: "futures", error: e.message }
end
end

View File

@@ -15,8 +15,9 @@ class BinanceItem::Importer
spot_result = BinanceItem::SpotImporter.new(binance_item, provider: binance_provider).import
margin_result = BinanceItem::MarginImporter.new(binance_item, provider: binance_provider).import
earn_result = BinanceItem::EarnImporter.new(binance_item, provider: binance_provider).import
futures_result = BinanceItem::FuturesImporter.new(binance_item, provider: binance_provider).import
all_assets = tagged_assets(spot_result) + tagged_assets(margin_result) + tagged_assets(earn_result)
all_assets = tagged_assets(spot_result) + tagged_assets(margin_result) + tagged_assets(earn_result) + tagged_assets(futures_result)
return { success: true, assets_imported: 0, total_usd: 0 } if all_assets.empty?
@@ -27,13 +28,15 @@ class BinanceItem::Importer
total_usd: total_usd,
spot_raw: spot_result[:raw],
margin_raw: margin_result[:raw],
earn_raw: earn_result[:raw]
earn_raw: earn_result[:raw],
futures_raw: futures_result[:raw]
)
binance_item.upsert_binance_snapshot!({
"spot" => spot_result[:raw],
"margin" => margin_result[:raw],
"earn" => earn_result[:raw],
"futures" => futures_result[:raw],
"imported_at" => Time.current.iso8601
})
@@ -68,7 +71,7 @@ class BinanceItem::Importer
0
end
def upsert_binance_account(all_assets:, total_usd:, spot_raw:, margin_raw:, earn_raw:)
def upsert_binance_account(all_assets:, total_usd:, spot_raw:, margin_raw:, earn_raw:, futures_raw:)
ba = binance_item.binance_accounts.find_or_initialize_by(account_type: "combined")
ba.assign_attributes(
@@ -80,6 +83,7 @@ class BinanceItem::Importer
"spot" => spot_raw,
"margin" => margin_raw,
"earn" => earn_raw,
"futures" => futures_raw,
"assets" => all_assets.map(&:stringify_keys),
"fetched_at" => Time.current.iso8601
}
@@ -90,7 +94,7 @@ class BinanceItem::Importer
end
def build_institution_metadata(all_assets)
%w[spot margin earn].each_with_object({}) do |source, hash|
%w[spot margin earn futures].each_with_object({}) do |source, hash|
source_assets = all_assets.select { |a| a[:source] == source }
hash[source] = {
"asset_count" => source_assets.size,

View File

@@ -31,27 +31,24 @@ class Budget < ApplicationRecord
end
def budget_date_valid?(date, family:)
budget_start = if family.uses_custom_month_start?
family.custom_month_start_for(date)
else
date.beginning_of_month
end
budget_start, _ = period_for(date, family: family)
budget_start >= oldest_valid_budget_date(family) &&
budget_start <= latest_valid_budget_start_date(family)
end
def period_for(date, family:)
if family.uses_custom_month_start?
[ family.custom_month_start_for(date), family.custom_month_end_for(date) ]
else
[ date.beginning_of_month, date.end_of_month ]
end
end
def find_or_bootstrap(family, start_date:, user: nil)
return nil unless budget_date_valid?(start_date, family: family)
Budget.transaction do
if family.uses_custom_month_start?
budget_start = family.custom_month_start_for(start_date)
budget_end = family.custom_month_end_for(start_date)
else
budget_start = start_date.beginning_of_month
budget_end = start_date.end_of_month
end
budget_start, budget_end = period_for(start_date, family: family)
budget = Budget.find_or_create_by!(
family: family,

View File

@@ -198,11 +198,9 @@ class BudgetCategory < ApplicationRecord
# Returns hash with suggested daily spending info or nil if not applicable
def suggested_daily_spending
return nil unless available_to_spend > 0
return nil unless budget.current?
budget_date = budget.start_date
return nil unless budget_date.month == Date.current.month && budget_date.year == Date.current.year
days_remaining = (budget_date.end_of_month - Date.current).to_i + 1
days_remaining = (budget.end_date - Date.current).to_i + 1
return nil unless days_remaining > 0
{

View File

@@ -0,0 +1,91 @@
class Category::Merger
class UnauthorizedCategoryError < StandardError; end
attr_reader :family, :target_category, :source_categories, :merged_count
def initialize(family:, target_category:, source_categories:)
@family = family
@target_category = target_category
@merged_count = 0
validate_category_belongs_to_family!(target_category, "Target category")
sources = Array(source_categories)
sources.each { |category| validate_category_belongs_to_family!(category, "Source category '#{category.name}'") }
@source_categories = sources.reject { |category| category.id == target_category.id }
validate_hierarchy!
validate_reparenting!
end
def merge!
return false if source_categories.empty?
Category.transaction { merge_sources! }
true
end
private
def merge_sources!
source_categories.each do |source|
family.transactions.where(category_id: source.id).update_all(category_id: target_category.id)
merge_budget_categories(source)
family.categories.where(parent_id: source.id).where.not(id: target_category.id).update_all(parent_id: target_category.id)
family.categories.find(source.id).destroy!
@merged_count += 1
end
end
def validate_category_belongs_to_family!(category, label)
return if category&.family_id == family.id
raise UnauthorizedCategoryError, "#{label} does not belong to this family"
end
def validate_hierarchy!
target_ancestor_ids = ancestor_ids_for(target_category)
return unless source_categories.any? { |source| target_ancestor_ids.include?(source.id) }
raise UnauthorizedCategoryError, "A parent category cannot be merged into its own subcategory"
end
def validate_reparenting!
return if target_category.parent_id.blank?
return unless source_categories.any? { |source| family.categories.exists?(parent_id: source.id) }
raise UnauthorizedCategoryError, "Cannot merge a category with subcategories into a subcategory"
end
def ancestor_ids_for(category)
ids = []
seen_ids = Set.new
current = category
while current&.parent_id.present? && seen_ids.exclude?(current.parent_id)
ids << current.parent_id
seen_ids << current.parent_id
current = family.categories.find_by(id: current.parent_id)
end
ids
end
def merge_budget_categories(source)
family.budget_categories.where(category_id: source.id).find_each do |source_budget_category|
target_budget_category = family.budget_categories.find_by(
budget_id: source_budget_category.budget_id,
category_id: target_category.id
)
if target_budget_category
target_budget_category.update!(
budgeted_spending: (target_budget_category.budgeted_spending || 0).to_d +
(source_budget_category.budgeted_spending || 0).to_d
)
source_budget_category.destroy!
else
source_budget_category.update!(category_id: target_category.id)
end
end
end
end

View File

@@ -88,8 +88,27 @@ class Holding::Materializer
"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
# No new calculated value — fall back to the most recent provider
# cost_basis for this security on or before the holding date.
# Calculated/manual values outrank a provider carry-forward.
existing_source = existing&.cost_basis_source
preserve_existing = existing&.cost_basis.present? && %w[calculated manual].include?(existing_source)
if preserve_existing
holdings_to_upsert_without_cost << base_attrs
else
carried = carry_forward_provider_cost_basis(holding)
if carried && (existing&.cost_basis != carried || existing_source != "provider")
holdings_to_upsert_with_cost << base_attrs.merge(
"cost_basis" => carried,
"cost_basis_source" => "provider"
)
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
end
end
@@ -165,6 +184,50 @@ class Holding::Materializer
[ holding.account_id || account.id, holding.security_id, holding.date, holding.currency ]
end
# Returns the most recent provider-supplied cost_basis for the given holding's
# security on or before its date, converted to the holding's currency.
# Used to backfill calculated rows past the provider's last snapshot so
# reports keep showing trend data.
#
# Provider and calculated rows can be denominated in different currencies
# (e.g., IBKR reports USD holdings while the reverse calculator converts to
# the account's base currency). When they differ, the cost_basis is converted
# at the snapshot date — the same convention ReverseCalculator uses for trade
# prices — so the result is consistent with trade-derived cost_basis values.
def carry_forward_provider_cost_basis(holding)
snapshots = provider_cost_basis_snapshots[holding.security_id]
return nil if snapshots.blank?
result = nil
snapshots.each do |snap_date, cost_basis, snap_currency|
break if snap_date > holding.date
result = [ cost_basis, snap_currency, snap_date ]
end
return nil unless result
cost_basis, snap_currency, snap_date = result
return cost_basis if snap_currency == holding.currency
Money.new(cost_basis, snap_currency).exchange_to(holding.currency, date: snap_date).amount
rescue Money::ConversionError
nil
end
def provider_cost_basis_snapshots
@provider_cost_basis_snapshots ||= begin
ids = @holdings.map(&:security_id).uniq
account.holdings
.where.not(account_provider_id: nil)
.where.not(cost_basis: nil)
.where(security_id: ids)
.order(:date) # ascending required: carry_forward_provider_cost_basis scans and breaks on snap_date > holding.date
.pluck(:security_id, :currency, :date, :cost_basis)
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(security_id, currency, date, cost_basis), memo|
memo[security_id] << [ date, cost_basis, currency ]
end
end
end
def purge_stale_holdings
portfolio_security_ids = account.trades.distinct.pluck(:security_id)

View File

@@ -40,6 +40,7 @@ class Import < ApplicationRecord
belongs_to :family
belongs_to :account, optional: true
belongs_to :account_statement, optional: true
before_validation :set_default_number_format
before_validation :ensure_utf8_encoding

View File

@@ -156,6 +156,77 @@ class InvestmentStatement
)
end
def period_return_trend(period: Period.current_month)
currency = family.currency
account_ids = investment_account_ids
return nil if account_ids.empty?
absolute_return = ActiveRecord::Base.connection.select_value(
ActiveRecord::Base.sanitize_sql_array([
<<~SQL.squish,
SELECT COALESCE(SUM(b.net_market_flows * COALESCE(er.rate, 1)), 0)
FROM balances b
JOIN accounts a ON a.id = b.account_id
LEFT JOIN exchange_rates er ON (
er.date = b.date
AND er.from_currency = b.currency
AND er.to_currency = :currency
)
WHERE a.id IN (:account_ids)
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND b.date BETWEEN :start_date AND :end_date
SQL
{
currency: currency,
account_ids: account_ids,
family_id: family.id,
start_date: period.date_range.begin,
end_date: period.date_range.end
}
])
).to_d
period_start = period.date_range.begin
# Single query for all accounts' most recent pre-period balance (strict < to avoid
# double-counting the first day's net_market_flows in both the denominator and absolute_return).
# FX conversion is done in SQL (matching absolute_return) so balance rows whose currency
# differs from the account's current currency (e.g. after a currency change) are still picked up.
start_value = ActiveRecord::Base.connection.select_value(
ActiveRecord::Base.sanitize_sql_array([
<<~SQL.squish,
SELECT COALESCE(SUM(b.end_balance * COALESCE(er.rate, 1)), 0)
FROM accounts a
INNER JOIN balances b ON b.account_id = a.id
LEFT JOIN exchange_rates er ON (
er.date = :period_start
AND er.from_currency = b.currency
AND er.to_currency = :currency
)
INNER JOIN (
SELECT b2.account_id, MAX(b2.date) AS max_date
FROM balances b2
WHERE b2.account_id IN (:account_ids)
AND b2.date < :period_start
GROUP BY b2.account_id
) latest ON latest.account_id = b.account_id AND b.date = latest.max_date
WHERE a.id IN (:account_ids)
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
SQL
{ account_ids: account_ids, period_start: period_start, family_id: family.id, currency: currency }
])
).to_d
return nil if start_value.zero?
Trend.new(
current: Money.new(start_value + absolute_return, currency),
previous: Money.new(start_value, currency)
)
end
# Day change across portfolio, summed in family currency
def day_change
changes = current_holdings.to_a.filter_map do |h|

View File

@@ -32,6 +32,7 @@ class Invitation < ApplicationRecord
return false if user.blank?
return false unless pending?
return false unless emails_match?(user)
return false if would_orphan_owned_accounts?(user)
transaction do
user.update!(family_id: family_id, role: role.to_s)
@@ -41,6 +42,14 @@ class Invitation < ApplicationRecord
true
end
def would_orphan_owned_accounts?(user)
return false if user.blank?
return false if user.family_id.blank?
return false if user.family_id == family_id
user.owned_accounts.where.not(family_id: family_id).exists?
end
private
def emails_match?(user)

View File

@@ -2,6 +2,32 @@ class PdfImport < Import
has_one_attached :pdf_file, dependent: :purge_later
validates :document_type, inclusion: { in: DOCUMENT_TYPES }, allow_nil: true
validate :account_statement_matches_import
class << self
def create_from_upload!(family:, file:, user:)
statement = AccountStatement.create_from_prepared_upload!(
family: family,
account: nil,
prepared_upload: AccountStatement.prepare_upload!(file)
)
create_from_statement!(statement: statement)
rescue AccountStatement::DuplicateUploadError => e
raise unless e.statement.manageable_by?(user)
create_from_statement!(statement: e.statement)
end
def create_from_statement!(statement:)
reusable_import = statement.latest_reusable_pdf_import
return reusable_import if reusable_import &&
reusable_import.account_id == statement.account_id &&
reusable_import.date_format == statement.family.date_format
create!(family: statement.family, account: statement.account, account_statement: statement, date_format: statement.family.date_format, status: :pending)
end
end
def import!
raise "Account required for PDF import" unless account.present?
@@ -31,8 +57,18 @@ class PdfImport < Import
end
end
def assign_account!(account)
transaction do
update!(account: account)
if (statement = account_statement)
statement.lock!
statement.link_to_account!(account) if statement.account_id != account.id
end
end
end
def pdf_uploaded?
pdf_file.attached?
statement_backed? || pdf_file.attached?
end
def ai_processed?
@@ -40,7 +76,16 @@ class PdfImport < Import
end
def process_with_ai_later
ProcessPdfJob.perform_later(self)
return false unless with_lock { pending? && !ai_processed? && rows_count.zero? && pdf_uploaded? && update!(status: :importing) }
begin
ProcessPdfJob.perform_later(self)
true
rescue StandardError => e
Rails.logger.error("Failed to enqueue PDF processing for import #{id}: #{e.class.name} - #{e.message}")
reload.with_lock { update!(status: :pending) }
false
end
end
def process_with_ai
@@ -172,9 +217,20 @@ class PdfImport < Import
end
def pdf_file_content
return nil unless pdf_file.attached?
return @pdf_file_content if defined?(@pdf_file_content)
return @pdf_file_content = account_statement.original_file.download if statement_backed?
pdf_file.download
@pdf_file_content = pdf_file.download if pdf_file.attached?
end
def pdf_filename
return account_statement.filename if statement_backed?
pdf_file.filename.to_s if pdf_file.attached?
end
def statement_backed?
account_statement&.original_file&.attached?
end
def required_column_keys
@@ -199,4 +255,10 @@ class PdfImport < Import
rescue ArgumentError
date_str.to_s
end
def account_statement_matches_import
return if account_statement.blank? || (account_statement.family_id == family_id && account_statement.pdf?)
errors.add(:account_statement, :invalid)
end
end

View File

@@ -45,15 +45,24 @@ class PlaidItem < ApplicationRecord
access_token: access_token
)
rescue Plaid::ApiError => e
error_body = JSON.parse(e.response_body)
if error_body["error_code"] == "ITEM_NOT_FOUND"
# Mark the connection as invalid but don't auto-delete
update!(status: :requires_update)
error_body = begin
JSON.parse(e.response_body.to_s)
rescue JSON::ParserError
{}
end
Sentry.capture_exception(e)
nil
if error_body["error_code"] == "ITEM_NOT_FOUND"
# Mark the connection as invalid but don't auto-delete. The caller
# gets nil so the calling controller can decide what to render.
update!(status: :requires_update)
Sentry.capture_exception(e) if defined?(Sentry)
nil
else
# Re-raise so the controller can surface a friendly alert to the user
# (issue #1792). Swallowing here previously left the Plaid modal frame
# blank with no actionable signal.
raise
end
end
def destroy_later

View File

@@ -13,6 +13,7 @@ class Provider::Binance
# Pipelock incorrectly interprets the '@' in Ruby instance variables as a password delimiter
# in an URL (e.g. https://user:password@host).
SPOT_BASE_URL = "https://api.binance.com".freeze
FUTURES_BASE_URL = "https://fapi.binance.com".freeze
base_uri SPOT_BASE_URL
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
@@ -87,14 +88,63 @@ class Provider::Binance
signed_get("/api/v3/myTrades", extra_params: params)
end
# USDⓈ-M Futures account — requires signed request
def get_futures_account
signed_get("/fapi/v2/account", base_url: FUTURES_BASE_URL)
end
# Futures trade history for a single symbol
def get_futures_trades(symbol, limit: 1000, from_id: nil)
params = { "symbol" => symbol, "limit" => limit.to_s }
params["fromId"] = from_id.to_s if from_id
signed_get("/fapi/v1/userTrades", extra_params: params, base_url: FUTURES_BASE_URL)
end
# P2P trade history — requires signed request
# Pass start_timestamp to fetch only recent trades (max 30 days window)
def get_p2p_trades(start_timestamp: nil, end_timestamp: nil)
params = { "tradeType" => "BUY" } # default to BUY, will loop in processor for SELL
params["startTimestamp"] = start_timestamp.to_s if start_timestamp
params["endTimestamp"] = end_timestamp.to_s if end_timestamp
signed_get("/sapi/v1/c2c/orderMatch/listUserOrderHistory", extra_params: params)
end
# Internal helper to handle both buy and sell types since API requires specific tradeType or gets default BUY
def get_all_p2p_trades(start_timestamp: nil, end_timestamp: nil)
%w[BUY SELL].flat_map do |trade_type|
page = 1
rows = 100
data = []
loop do
result = signed_get(
"/sapi/v1/c2c/orderMatch/listUserOrderHistory",
extra_params: {
"tradeType" => trade_type,
"startTimestamp" => start_timestamp&.to_s,
"endTimestamp" => end_timestamp&.to_s,
"page" => page.to_s,
"rows" => rows.to_s
}.compact
)
batch = result.is_a?(Hash) ? Array(result["data"]) : []
data.concat(batch)
break if batch.size < rows
page += 1
end
data
end
end
private
def signed_get(path, extra_params: {})
def signed_get(path, extra_params: {}, base_url: SPOT_BASE_URL)
params = timestamp_params.merge(extra_params)
query_string = URI.encode_www_form(params.sort)
full_url = "#{base_url}#{path}"
response = self.class.get(
path,
full_url,
query: "#{query_string}&signature=#{sign(query_string)}",
headers: auth_headers
)

View File

@@ -71,6 +71,14 @@ class Sync < ApplicationRecord
query
end
# True iff the family has any pending/syncing Sync — across its own row,
# its accounts, and every Syncable provider `*_items` association. Built
# on `for_family` so new provider integrations are picked up automatically
# via `family_syncable_associations` reflection (no hand-rolled list).
def any_incomplete_for?(family)
for_family(family).incomplete.exists?
end
private
def account_syncable_ids(family, resource_owner)
(resource_owner ? resource_owner.accessible_accounts : family.accounts)

View File

@@ -17,7 +17,7 @@
) %>
<% end %>
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? %>
<% if @manual_accounts.empty? && @plaid_items.empty? && @simplefin_items.empty? && @lunchflow_items.empty? && @enable_banking_items.empty? && @coinstats_items.empty? && @coinbase_items.empty? && @mercury_items.empty? && @brex_items.empty? && @ibkr_items.empty? && @snaptrade_items.empty? && @indexa_capital_items.empty? && @sophtron_items.empty? && @binance_items.empty? %>
<%= render "empty" %>
<% else %>
<div class="space-y-2">
@@ -57,6 +57,10 @@
<%= render @coinbase_items.sort_by(&:created_at) %>
<% end %>
<% if @binance_items.any? %>
<%= render @binance_items.sort_by(&:created_at) %>
<% end %>
<% if @snaptrade_items.any? %>
<%= render @snaptrade_items.sort_by(&:created_at) %>
<% end %>

View File

@@ -67,7 +67,7 @@
<% end %>
<%= render DS::Menu.new do |menu| %>
<% if unlinked_count.to_i > 0 %>
<% if binance_item.unlinked_accounts_count > 0 %>
<% menu.with_item(
variant: "link",
text: t(".import_accounts_menu"),
@@ -110,10 +110,10 @@
provider_item: binance_item
) %>
<% if unlinked_count.to_i > 0 && binance_item.accounts.empty? %>
<div class="p-4 flex flex-col gap-3 items-center justify-center">
<% if binance_item.unlinked_accounts_count > 0 %>
<div class="p-4 flex flex-col gap-3 items-center justify-center bg-surface border border-primary rounded-xl">
<p class="text-primary font-medium text-sm"><%= t(".setup_needed") %></p>
<p class="text-secondary text-sm"><%= t(".setup_description") %></p>
<p class="text-secondary text-sm text-center"><%= t(".setup_description") %></p>
<%= render DS::Link.new(
text: t(".setup_action"),
icon: "plus",

View File

@@ -33,6 +33,18 @@
</div>
</div>
<div class="bg-surface border border-primary p-4 rounded-lg space-y-3">
<p class="text-sm font-medium text-primary"><%= t(".historical_import") %></p>
<div class="field">
<%= form.label :sync_start_date, t("settings.providers.binance_panel.sync_start_date_label"), class: "label" %>
<%= form.date_field :sync_start_date,
value: @binance_item.sync_start_date || (Date.current - 1.year),
max: Date.current,
class: "input" %>
<p class="help-text mt-1 text-xs text-secondary"><%= t("settings.providers.binance_panel.sync_start_date_help") %></p>
</div>
</div>
<% if @binance_accounts.empty? %>
<div class="text-center py-8">
<p class="text-secondary"><%= t(".no_accounts") %></p>
@@ -69,7 +81,7 @@
<%= binance_account.currency %>
</p>
</div>
<div class="text-right flex-shrink-0">
<div class="text-right shrink-0">
<p class="text-sm font-medium text-primary">
<%= number_with_delimiter(binance_account.current_balance || 0, delimiter: ",") %>
</p>

View File

@@ -1,7 +1,7 @@
<%# locals: (category:) %>
<% category ||= Category.uncategorized %>
<div class="min-w-0 w-full">
<div class="min-w-0">
<span class="flex w-full items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border focus-visible:outline-none focus-visible:ring-0"
style="
background-color: color-mix(in oklab, <%= category.color %> 10%, transparent);

View File

@@ -10,6 +10,14 @@
confirm: CustomConfirm.for_resource_deletion("all categories", high_severity: true)) %>
<% end %>
<%= render DS::Link.new(
text: t(".merge"),
variant: "outline",
icon: "combine",
href: merge_categories_path,
frame: :modal
) %>
<%= render DS::Link.new(
text: t(".new"),
variant: "primary",

View File

@@ -0,0 +1,30 @@
<%= render DS::Dialog.new do |dialog| %>
<% dialog.with_header(title: t(".title"), subtitle: t(".description")) %>
<% dialog.with_body do %>
<%= styled_form_with url: perform_merge_categories_path, method: :post, class: "space-y-4" do |f| %>
<%= f.collection_select :target_id,
@categories,
:id, :name_with_parent,
{ prompt: t(".select_target"), label: t(".target_label") },
{ required: true } %>
<div class="space-y-2">
<p class="text-sm font-medium text-primary"><%= t(".sources_label") %></p>
<div class="space-y-1 border border-secondary rounded-lg p-2">
<% @categories.each do |category| %>
<label class="flex items-center gap-2 p-2 hover:bg-surface-hover rounded cursor-pointer">
<%= check_box_tag "source_ids[]", category.id, false, class: "rounded border-secondary" %>
<span class="text-sm text-primary"><%= category.name_with_parent %></span>
</label>
<% end %>
</div>
<p class="text-xs text-subdued"><%= t(".sources_hint") %></p>
</div>
<%= render DS::Button.new(
text: t(".submit"),
full_width: true
) %>
<% end %>
<% end %>
<% end %>

View File

@@ -45,9 +45,7 @@
<div class="flex items-center gap-2 flex-wrap">
<p class="font-medium text-sm text-primary"><%= aspsp[:name] %></p>
<% if aspsp[:beta] %>
<span class="text-xs font-medium text-warning bg-warning/10 px-2 py-0.5 rounded-full flex-shrink-0">
<%= t(".beta_label", default: "Beta") %>
</span>
<%= render DS::Pill.new(label: t(".beta_label", default: "Beta"), tone: :warning, marker: false) %>
<% end %>
</div>
<% if aspsp[:bic].present? %>
@@ -70,9 +68,12 @@
<% end %>
<div class="flex justify-end pt-4">
<%= link_to t(".cancel", default: "Cancel"), settings_providers_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= render DS::Link.new(
text: t(".cancel", default: "Cancel"),
variant: :secondary,
href: settings_providers_path,
data: { turbo_frame: "_top", action: "DS--dialog#close" }
) %>
</div>
</div>
<% end %>

View File

@@ -14,16 +14,25 @@
</div>
<div class="bg-container border border-primary rounded-xl p-4 space-y-4">
<% if import.account_statement.present? %>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.source_statement") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-container-inset rounded-lg">
<%= link_to import.account_statement.filename, account_statement_path(import.account_statement), class: "text-primary hover:text-primary-hover" %>
</p>
</div>
<% end %>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.document_type_label") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-gray-tint-5 rounded-lg">
<p class="text-sm text-secondary px-3 py-2 bg-container-inset rounded-lg">
<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>
</p>
</div>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.transactions_extracted", default: "Transactions Extracted") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-gray-tint-5 rounded-lg">
<p class="text-sm text-secondary px-3 py-2 bg-container-inset rounded-lg">
<%= t("imports.pdf_import.transactions_extracted_count", count: import.rows_count, default: "%{count} transactions") %>
</p>
</div>
@@ -38,7 +47,7 @@
<p class="text-xs text-secondary"><%= t("imports.pdf_import.select_account_hint", default: "Choose which account to import these transactions into.") %></p>
<% end %>
<% else %>
<p class="text-sm text-secondary px-3 py-2 bg-yellow-500/10 rounded-lg">
<p class="text-sm text-secondary px-3 py-2 bg-warning/10 rounded-lg">
<%= t("imports.pdf_import.no_accounts", default: "No accounts available. Please create an account first.") %>
</p>
<%= render DS::Link.new(text: t("imports.pdf_import.create_account", default: "Create Account"), href: new_account_path(return_to: import_path(import)), variant: "primary", full_width: true, frame: :modal) %>
@@ -106,16 +115,25 @@
</div>
<div class="bg-container border border-primary rounded-xl p-4 space-y-4">
<% if import.account_statement.present? %>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.source_statement") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-container-inset rounded-lg">
<%= link_to import.account_statement.filename, account_statement_path(import.account_statement), class: "text-primary hover:text-primary-hover" %>
</p>
</div>
<% end %>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.document_type_label") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-gray-tint-5 rounded-lg">
<p class="text-sm text-secondary px-3 py-2 bg-container-inset rounded-lg">
<%= t("imports.document_types.#{import.document_type}", default: import.document_type&.humanize || t("imports.pdf_import.unknown_document_type", default: "Unknown")) %>
</p>
</div>
<div class="space-y-2">
<h2 class="font-medium text-primary"><%= t("imports.pdf_import.summary_label") %></h2>
<p class="text-sm text-secondary px-3 py-2 bg-gray-tint-5 rounded-lg whitespace-pre-wrap">
<p class="text-sm text-secondary px-3 py-2 bg-container-inset rounded-lg whitespace-pre-wrap">
<%= import.ai_summary %>
</p>
</div>

View File

@@ -23,7 +23,8 @@
<% end %>
</div>
<%= icon("arrow-up", as_button: true, type: "submit") %>
<%= icon("arrow-up", as_button: true, type: "submit",
data: { chat_target: "submit" }) %>
</div>
<% end %>

View File

@@ -3,7 +3,7 @@
<% if investment_metrics[:has_investments] %>
<div class="space-y-6">
<%# Investment Summary Cards %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<%# Portfolio Value Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
@@ -31,6 +31,22 @@
<% end %>
</div>
<%# Period Return Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<%= icon("bar-chart-2", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.period_return") %></span>
</div>
<% if investment_metrics[:period_return_trend] %>
<p class="text-xl font-semibold privacy-sensitive" style="color: <%= investment_metrics[:period_return_trend].color %>">
<%= format_money(Money.new(investment_metrics[:period_return_trend].value, Current.family.currency)) %>
(<%= investment_metrics[:period_return_trend].percent_formatted %>)
</p>
<% else %>
<p class="text-xl font-semibold text-secondary"><%= t("reports.investment_performance.no_data") %></p>
<% end %>
</div>
<%# Period Contributions Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">

View File

@@ -210,6 +210,17 @@
</span>
</div>
<% end %>
<% if @investment_metrics[:period_return_trend] %>
<div class="tufte-metric-card tufte-metric-card-sm">
<span class="tufte-metric-card-label"><%= t("reports.print.investments.period_return") %></span>
<span class="tufte-metric-card-value" style="color: <%= @investment_metrics[:period_return_trend].color %>">
<%= @investment_metrics[:period_return_trend].value >= 0 ? "+" : "" %><%= format_money(Money.new(@investment_metrics[:period_return_trend].value, Current.family.currency)) %>
</span>
<span class="tufte-metric-card-change" style="color: <%= @investment_metrics[:period_return_trend].color %>">
<%= @investment_metrics[:period_return_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>

View File

@@ -9,7 +9,7 @@ nav_sections = [
{ label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" },
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
{ label: t(".payment_label"), path: settings_payment_path, icon: "circle-dollar-sign", if: !self_hosted? && Current.family.can_manage_subscription? }
{ label: t(".payment_label"), path: settings_payment_path, icon: "circle-dollar-sign", if: !self_hosted? && Current.family&.can_manage_subscription? }
]
},
{

View File

@@ -54,6 +54,15 @@
</div>
</div>
<div class="flex items-center gap-2">
<% if item.unlinked_accounts_count > 0 %>
<%= render DS::Link.new(
text: t("binance_items.binance_item.setup_action"),
icon: "plus",
variant: "primary",
href: setup_accounts_binance_item_path(item),
frame: :modal
) %>
<% end %>
<%= button_to sync_binance_item_path(item),
method: :post,
class: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-secondary hover:text-primary border border-secondary rounded-lg hover:border-primary",

View File

@@ -180,8 +180,8 @@
</div>
<div class="hidden md:flex min-w-0 items-center gap-1 col-span-2">
<% if entry.account.investment? && !transaction.transfer? %>
<%# For investment accounts, show activity label instead of category %>
<% if entry.account.supports_trades? && !transaction.transfer? %>
<%# For investment/crypto accounts, show activity label instead of category %>
<%= render "investment_activity/quick_edit_badge", entry: entry, entryable: transaction %>
<% else %>
<%= render "transactions/transaction_category", transaction: transaction, variant: "desktop", in_split_group: in_split_group %>

View File

@@ -1,9 +1,14 @@
<%# locals: (form:) %>
<div data-controller="list-filter">
<div class="relative">
<input type="search" autocomplete="off" placeholder="Filter accounts" data-list-filter-target="input" data-action="input->list-filter#filter" class="block w-full border border-secondary rounded-md py-2 pl-10 pr-3 bg-container focus:ring-gray-500 sm:text-sm">
<%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
</div>
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t(".filter_accounts"),
aria_label: t(".filter_accounts"),
data: {
list_filter_target: "input",
action: "input->list-filter#filter"
}
) %>
<div class="my-2" id="list" data-list-filter-target="list">
<% Current.user.accessible_accounts.alphabetically.each do |account| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= account.name %>">

View File

@@ -1,9 +1,14 @@
<%# locals: (form:) %>
<div data-controller="list-filter">
<div class="relative">
<input type="search" autocomplete="off" placeholder="Filter category" data-list-filter-target="input" data-action="input->list-filter#filter" class="block w-full bg-container border border-secondary rounded-md py-2 pl-10 pr-3 focus:ring-gray-500 sm:text-sm">
<%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
</div>
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t(".filter_category"),
aria_label: t(".filter_category"),
data: {
list_filter_target: "input",
action: "input->list-filter#filter"
}
) %>
<div class="my-2" id="list" data-list-filter-target="list">
<% family_categories.each do |category| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= category.name %>">

View File

@@ -3,9 +3,9 @@
<%= form.date_field :start_date,
placeholder: t(".start_date"),
value: @q[:start_date],
class: "block w-full border border-secondary rounded-md bg-container py-2 pl-3 pr-3 focus:ring-gray-500 sm:text-sm" %>
class: "block w-full border border-secondary rounded-md bg-container py-2 pl-3 pr-3 text-base sm:text-sm focus-visible:ring-2 focus-visible:ring-alpha-black-300" %>
<%= form.date_field :end_date,
placeholder: t(".end_date"),
value: @q[:end_date],
class: "block w-full border border-secondary rounded-md bg-container py-2 pl-3 pr-3 focus:ring-gray-500 sm:text-sm mt-2" %>
class: "block w-full border border-secondary rounded-md bg-container py-2 pl-3 pr-3 text-base sm:text-sm mt-2 focus-visible:ring-2 focus-visible:ring-alpha-black-300" %>
</div>

View File

@@ -1,9 +1,14 @@
<%# locals: (form:) %>
<div data-controller="list-filter">
<div class="relative">
<input type="search" autocomplete="off" placeholder="Filter merchants" data-list-filter-target="input" data-action="input->list-filter#filter" class="block w-full bg-container border border-secondary rounded-md py-2 pl-10 pr-3 focus:ring-gray-500 sm:text-sm">
<%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
</div>
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t(".filter_merchants"),
aria_label: t(".filter_merchants"),
data: {
list_filter_target: "input",
action: "input->list-filter#filter"
}
) %>
<div class="my-2" id="list" data-list-filter-target="list">
<% Current.family.assigned_merchants_for(Current.user).alphabetically.each do |merchant| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= merchant.name %>">

View File

@@ -1,9 +1,14 @@
<%# locals: (form:) %>
<div data-controller="list-filter">
<div class="relative">
<input type="search" autocomplete="off" placeholder="Filter tags" data-list-filter-target="input" data-action="input->list-filter#filter" class="block w-full bg-container border border-secondary rounded-md py-2 pl-10 pr-3 focus:ring-gray-500 sm:text-sm">
<%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
</div>
<%= render DS::SearchInput.new(
variant: :embedded,
placeholder: t(".filter_tags"),
aria_label: t(".filter_tags"),
data: {
list_filter_target: "input",
action: "input->list-filter#filter"
}
) %>
<div class="my-2" id="list" data-list-filter-target="list">
<% Current.family.tags.alphabetically.each do |tag| %>
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= tag.name %>">

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
%w[json pathname yaml].each { |library| require library }
ROOT = File.expand_path("..", __dir__)
WORKFLOW_PATH = File.join(ROOT, ".github/workflows/preview-deploy.yml")
LOCKFILE_PATH = File.join(ROOT, "workers/preview/package-lock.json")
PINNED_ACTION = /\A[^@\s]+@[a-f0-9]{40}\z/
INLINE_SECRET_EXPRESSION = /\$\{\{\s*secrets\s*(?:\.|\[)/i
INLINE_PR_EXPRESSION = /
\$\{\{\s*
github\s*
(?:\.\s*event|\[\s*['"]event['"]\s*\])\s*
(?:\.\s*pull_request|\[\s*['"]pull_request['"]\s*\])
/ix
PR_CONTROLLED_WORKDIR = %r{\A(?:pr|workers/preview)(?:/|\z)}
GITHUB_WORKSPACE_PREFIX = %r{
\A
(?:
\$GITHUB_WORKSPACE |
\$\{\{\s*github\s*(?:\.\s*workspace|\[\s*['"]workspace['"]\s*\])\s*\}\}
)
(?:/|\z)
}ix
EXPECTED_PERMISSIONS = { "actions" => "read", "contents" => "read", "pull-requests" => "write", "deployments" => "write" }.freeze
EXPECTED_SECRET_ENV = %w[CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN CLOUDFLARE_WORKERS_SUBDOMAIN].freeze
REQUIRED_PREPARE_LINES = [
'cp trusted/workers/preview/package.json "$preview_dir/package.json"',
'cp trusted/workers/preview/package-lock.json "$preview_dir/package-lock.json"',
'cp trusted/workers/preview/tsconfig.json "$preview_dir/tsconfig.json"',
'cp trusted/workers/preview/wrangler.toml "$preview_dir/wrangler.toml"',
'cp -R pr/workers/preview/src "$preview_dir/src"',
'image = \"${GITHUB_WORKSPACE}/pr/Dockerfile.preview\"',
"npm ci --ignore-scripts --no-audit --no-fund"
].freeze
def fail_check(message)
warn "preview-deploy security check failed: #{message}"
exit 1
end
def assert(value, message)
fail_check(message) unless value
end
def step!(steps, name)
steps.find { |step| step["name"] == name } || fail_check("missing #{name.inspect} step")
end
def run(step)
step.fetch("run", "")
end
def assert_run_includes(step, *needles)
script = run(step)
needles.each { |needle| assert(script.include?(needle), "#{step["name"]} must include #{needle.inspect}") }
script
end
def normalized_working_directory(value)
path = value.to_s.strip.sub(GITHUB_WORKSPACE_PREFIX, "")
normalized = Pathname.new(path).cleanpath.to_s
normalized == "." ? "" : normalized
end
def environment_name(job)
environment = job["environment"]
environment.is_a?(Hash) ? environment["name"] : environment
end
workflow = YAML.safe_load_file(WORKFLOW_PATH, aliases: true)
lockfile = JSON.parse(File.read(LOCKFILE_PATH))
job = workflow.fetch("jobs").fetch("deploy-preview")
steps = job.fetch("steps")
step_names = steps.map { |step| step["name"] }
pr_checkout = step!(steps, "Checkout PR code")
trusted_checkout = step!(steps, "Checkout trusted preview tooling")
prepare = step!(steps, "Prepare trusted preview deploy workspace")
deploy = step!(steps, "Deploy to Cloudflare Containers")
wrangler = lockfile.fetch("packages").fetch("node_modules/wrangler")
[
[ "job permissions", job.fetch("permissions"), EXPECTED_PERMISSIONS ],
[ "job environment", environment_name(job), "preview" ],
[ "concurrency group", job.dig("concurrency", "group"), "preview-deploy-${{ github.event.pull_request.number }}" ],
[ "concurrency cancellation", job.dig("concurrency", "cancel-in-progress"), true ],
[ "PR_NUMBER env", job.dig("env", "PR_NUMBER"), "${{ github.event.pull_request.number }}" ],
[ "HEAD_SHA env", job.dig("env", "HEAD_SHA"), "${{ github.event.pull_request.head.sha }}" ],
[ "PR checkout path", pr_checkout.dig("with", "path"), "pr" ],
[ "PR checkout credentials", pr_checkout.dig("with", "persist-credentials"), false ],
[ "trusted checkout ref", trusted_checkout.dig("with", "ref"), "${{ github.event.pull_request.base.sha }}" ],
[ "trusted checkout path", trusted_checkout.dig("with", "path"), "trusted" ],
[ "trusted checkout credentials", trusted_checkout.dig("with", "persist-credentials"), false ],
[ "deploy secret env", deploy.fetch("env").keys.sort, EXPECTED_SECRET_ENV ],
[ "Wrangler binary", wrangler.dig("bin", "wrangler"), "bin/wrangler.js" ]
].each { |label, actual, expected| assert(actual == expected, "#{label}: expected #{actual.inspect} to equal #{expected.inspect}") }
assert(lockfile.dig("packages", "", "devDependencies", "wrangler"), "Wrangler must stay a root dev dependency")
assert(lockfile.fetch("lockfileVersion") >= 3, "preview tooling lockfile must preserve npm ci integrity metadata")
assert(wrangler.fetch("resolved").start_with?("https://registry.npmjs.org/wrangler/-/wrangler-"), "Wrangler must resolve from npm registry")
assert(wrangler.fetch("integrity").start_with?("sha512-"), "Wrangler lockfile entry must keep npm integrity metadata")
assert(trusted_checkout.dig("with", "sparse-checkout").to_s.include?("workers/preview"), "trusted checkout must include preview tooling")
assert(step_names.compact.uniq == step_names.compact, "workflow step names must stay unique for security checks")
assert([ pr_checkout, trusted_checkout, prepare, deploy ].map { |step| steps.index(step) }.each_cons(2).all? { |left, right| left < right }, "checkout, preparation, and deploy steps must stay ordered")
assert(job.fetch("env").keys.none? { |name| name.start_with?("CLOUDFLARE_") }, "Cloudflare secrets must not be job-wide")
assert(EXPECTED_SECRET_ENV.all? { |name| deploy.fetch("env").fetch(name).start_with?("${{ secrets.") }, "deploy secret env must be sourced from GitHub secrets")
steps.each do |step|
uses = step["uses"]
assert(uses.start_with?("./") || uses.match?(PINNED_ACTION), "#{step["name"] || uses} must pin external actions") if uses
end
inline_scripts = steps.flat_map { |step| [ run(step), step.dig("with", "script") ] }.compact.join("\n")
assert(!inline_scripts.match?(INLINE_SECRET_EXPRESSION), "secrets must enter scripts through env")
assert(!inline_scripts.match?(INLINE_PR_EXPRESSION), "PR fields must enter scripts through env")
assert(steps.none? { |step| normalized_working_directory(step["working-directory"]).match?(PR_CONTROLLED_WORKDIR) }, "steps must not run from PR-controlled dirs")
assert(steps.none? { |step| run(step).include?("npx wrangler") }, "workflow must not use npx wrangler")
prepare_run = assert_run_includes(prepare, *REQUIRED_PREPARE_LINES)
assert(!prepare_run.include?("npm install"), "prepare step must not use npm install")
assert(!prepare_run.include?("CLOUDFLARE_API_TOKEN"), "prepare step must not receive Cloudflare secrets")
assert([ prepare, deploy ].all? { |step| run(step).include?("set -euo pipefail") }, "trusted setup and deploy scripts must fail closed")
assert(prepare_run.include?('preview_dir="$RUNNER_TEMP/sure-preview-worker"'), "trusted workspace must be created under RUNNER_TEMP")
assert(steps.select { |step| run(step).match?(/npm (ci|install)/) }.map { |step| step["name"] } == [ prepare["name"] ], "only prepare may install deploy tooling")
secret_steps = steps.select { |step| step.fetch("env", {}).then { |env| env.key?("CLOUDFLARE_API_TOKEN") || env.key?("CLOUDFLARE_ACCOUNT_ID") } }
assert(secret_steps.map { |step| step["name"] } == [ deploy["name"] ], "only deploy may receive Cloudflare secrets")
secret_steps.each do |step|
assert(step["working-directory"].nil?, "#{step["name"]} must not run from a PR-controlled working directory")
assert(!run(step).match?(/npx wrangler|npm (ci|install)/), "#{step["name"]} must not execute PR-controlled tooling with secrets")
end
assert_run_includes(deploy, 'cd "$RUNNER_TEMP/sure-preview-worker"', "./node_modules/.bin/wrangler deploy --config wrangler.toml", '--var "PR_NUMBER:${PR_NUMBER}"')
puts "preview-deploy security check passed"

View File

@@ -880,6 +880,20 @@ btc:
delimiter: ","
default_format: "%u%n"
default_precision: 8
doge:
name: Dogecoin
priority: 100
iso_code: DOGE
iso_numeric: ""
html_code: "&#x00D0;"
symbol: "Ð"
minor_unit: Koinu
minor_unit_conversion: 100000000
smallest_denomination: 1
separator: "."
delimiter: ","
default_format: "%u%n"
default_precision: 8
jep:
name: Jersey Pound
priority: 100

View File

@@ -0,0 +1,87 @@
---
vi:
breadcrumbs:
account_sharings: Chia sẻ tài khoản
account_statements: Kho sao kê
accounts: Tài khoản
ai_prompts: Câu lệnh AI
api_key: Khóa API
api_keys: Khóa API
appearance: Giao diện
appearances: Giao diện
bank_sync: Đồng bộ ngân hàng
binance_items: Binance
brex_items: Brex
budget_categories: Danh mục ngân sách
budgets: Ngân sách
categories: Danh mục
categorize: Phân loại
chats: Trò chuyện
coinbase_items: Coinbase
coinstats_items: CoinStats
credit_cards: Thẻ tín dụng
cryptos: Tiền mã hóa
dashboard: Bảng điều khiển
debug: Gỡ lỗi
debugs: Gỡ lỗi
depositories: Tài khoản tiền mặt
enable_banking_items: Enable Banking
exports: Xuất dữ liệu
family_exports: Xuất dữ liệu
family_merchants: Nhà cung cấp
guides: Hướng dẫn
holdings: Danh mục nắm giữ
home: Trang chủ
hostings: Tự lưu trữ
ibkr_items: Interactive Brokers
impersonation_sessions: Mạo danh
imports: Nhập dữ liệu
indexa_capital_items: Indexa Capital
intro: Giới thiệu
investments: Đầu tư
invitations: Lời mời
invite_codes: Mã mời
kraken_items: Kraken
llm_usage: Sử dụng LLM
llm_usages: Sử dụng LLM
loans: Khoản vay
lunchflow_items: Lunch Flow
merchants: Nhà cung cấp
mercury_items: Mercury
messages: Tin nhắn
mfa: Xác thực hai yếu tố
oidc_accounts: Tài khoản SSO
onboardings: Khởi đầu
other_assets: Tài sản khác
other_liabilities: Nợ khác
payments: Thanh toán
pending_duplicate_merges: Xem xét trùng lặp
plaid_items: Plaid
preferences: Tùy chọn
profile: Thông tin hồ sơ
profiles: Thông tin hồ sơ
properties: Bất động sản
providers: Nhà cung cấp dịch vụ
recurring_transactions: Định kỳ
registrations: Đăng ký
reports: Báo cáo
rules: Quy tắc
securities: Chứng khoán
security: Bảo mật
self_hosting: Tự lưu trữ
sessions: Đăng nhập
simplefin_items: SimpleFIN
snaptrade_items: SnapTrade
sophtron_items: Sophtron
splits: Phân chia
sso_identities: Kết nối SSO
sso_providers: Nhà cung cấp SSO
subscriptions: Gói đăng ký
tags: Nhãn
trades: Giao dịch chứng khoán
transactions: Giao dịch
transfer_matches: Khớp chuyển khoản
transfers: Chuyển khoản
users: Người dùng
valuations: Định giá
vehicles: Phương tiện

View File

@@ -1,6 +1,87 @@
---
zh-CN:
breadcrumbs:
account_sharings: 账户共享
account_statements: 对账单库
accounts: 账户
ai_prompts: AI 提示词
api_key: API 密钥
api_keys: API 密钥
appearance: 外观
appearances: 外观
bank_sync: 银行同步
binance_items: Binance
brex_items: Brex
budget_categories: 预算分类
budgets: 预算
categories: 分类
categorize: 分类
chats: 聊天
coinbase_items: Coinbase
coinstats_items: CoinStats
credit_cards: 信用卡
cryptos: 加密资产
dashboard: 仪表盘
debug: 调试
debugs: 调试
depositories: 现金账户
enable_banking_items: Enable Banking
exports: 导出
family_exports: 导出
family_merchants: 商户
guides: 指南
holdings: 持仓
home: 主页
hostings: 自托管
ibkr_items: Interactive Brokers
impersonation_sessions: 模拟会话
imports: 导入
indexa_capital_items: Indexa Capital
intro: 介绍
investments: 投资
invitations: 邀请
invite_codes: 邀请码
kraken_items: Kraken
llm_usage: LLM 用量
llm_usages: LLM 用量
loans: 贷款
lunchflow_items: Lunch Flow
merchants: 商户
mercury_items: Mercury
messages: 消息
mfa: 双重验证
oidc_accounts: OIDC 账户
onboardings: 入门
other_assets: 其他资产
other_liabilities: 其他负债
payments: 付款
pending_duplicate_merges: 重复项审核
plaid_items: Plaid
preferences: 偏好设置
profile: 个人资料
profiles: 个人资料
properties: 房产
providers: 提供商
recurring_transactions: 循环交易
registrations: 注册
reports: 报表
rules: 规则
securities: 证券
security: 证券
self_hosting: 自托管
sessions: 登录
simplefin_items: SimpleFIN
snaptrade_items: SnapTrade
sophtron_items: Sophtron
splits: 拆分
sso_identities: SSO 连接
sso_providers: SSO 提供商
subscriptions: 订阅
tags: 标签
trades: 交易
transactions: 交易
transfer_matches: 转账匹配
transfers: 转账
users: 用户
valuations: 估值
vehicles: 车辆

View File

@@ -1,5 +1,12 @@
---
zh-CN:
defaults:
brand_name: "%{brand_name}"
product_name: "%{product_name}"
common:
close: 关闭
global:
expand: 展开
activerecord:
errors:
messages:
@@ -62,19 +69,43 @@ zh-CN:
- :day
datetime:
distance_in_words:
about_x_hours: 大约%{count}小时
about_x_months: 大约%{count}个月
about_x_years: 大约%{count}
almost_x_years: 接近%{count}年
about_x_hours:
one: 大约%{count}小时
other: 大约%{count}小时
about_x_months:
one: 大约%{count}个月
other: 大约%{count}个月
about_x_years:
one: 大约%{count}年
other: 大约%{count}年
almost_x_years:
one: 接近%{count}年
other: 接近%{count}年
half_a_minute: 半分钟
less_than_x_minutes: 不到%{count}分钟
less_than_x_seconds: 不到%{count}
over_x_years: "%{count}年多"
x_days: "%{count}天"
x_minutes: "%{count}分钟"
x_months: "%{count}个月"
x_seconds: "%{count}秒"
x_years: "%{count}年"
less_than_x_minutes:
one: 不到%{count}分钟
other: 不到%{count}分钟
less_than_x_seconds:
one: 不到%{count}
other: 不到%{count}
over_x_years:
one: "%{count}年"
other: "%{count}年多"
x_days:
one: "%{count}天"
other: "%{count}天"
x_minutes:
one: "%{count}分钟"
other: "%{count}分钟"
x_months:
one: "%{count}个月"
other: "%{count}个月"
x_seconds:
one: "%{count}秒"
other: "%{count}秒"
x_years:
one: "%{count}年"
other: "%{count}年"
prompts:
day:
hour:
@@ -107,15 +138,25 @@ zh-CN:
present: 必须是空白
required: 必须存在
taken: 已经被使用
too_long: 过长(最长为%{count}个字符)
too_short: (最为%{count}个字符)
wrong_length: 度非法(必须为%{count}个字符)
too_long:
one: (最为%{count}个字符)
other: 过长(最长为%{count}个字符)
too_short:
one: 过短(最短为%{count}个字符)
other: 过短(最短为%{count}个字符)
wrong_length:
one: 长度非法(必须为%{count}个字符)
other: 长度非法(必须为%{count}个字符)
template:
body: 如下字段出现错误:
header: 有%{count}个错误发生导致"%{model}"无法被保存。
header:
one: '%{count}个错误导致"%{model}"无法被保存。'
other: '%{count}个错误导致"%{model}"无法被保存。'
helpers:
select:
prompt: 请选择
search_placeholder: 搜索
default_label: 选择...
submit:
create: 新增%{model}
submit: 储存%{model}
@@ -137,10 +178,14 @@ zh-CN:
format: "%n %u"
units:
billion: 十亿
million: 百万
million:
one: 百万
other: 百万
quadrillion: 千兆
thousand:
trillion:
trillion:
one:
other:
unit: ''
format:
delimiter: ''
@@ -150,7 +195,9 @@ zh-CN:
storage_units:
format: "%n %u"
units:
byte: 字节
byte:
one: 字节
other: 字节
eb: EB
gb: GB
kb: KB
@@ -166,7 +213,7 @@ zh-CN:
delimiter: ''
support:
array:
last_word_connector: "、"
last_word_connector:
two_words_connector:
words_connector: "、"
time:

View File

@@ -0,0 +1,149 @@
vi:
activerecord:
attributes:
doorkeeper/application:
name: 'Tên'
redirect_uri: 'URI chuyển hướng'
errors:
models:
doorkeeper/application:
attributes:
redirect_uri:
fragment_present: 'không được chứa fragment.'
invalid_uri: 'phải là URI hợp lệ.'
unspecified_scheme: 'phải chỉ định scheme.'
relative_uri: 'phải là URI tuyệt đối.'
secured_uri: 'phải là URI HTTPS/SSL.'
forbidden_uri: 'bị cấm bởi máy chủ.'
scopes:
not_match_configured: "không khớp với cấu hình trên máy chủ."
doorkeeper:
applications:
confirmations:
destroy: 'Bạn có chắc không?'
buttons:
edit: 'Chỉnh sửa'
destroy: 'Xóa'
submit: 'Gửi'
cancel: 'Hủy'
authorize: 'Ủy quyền'
form:
error: 'Ối! Kiểm tra biểu mẫu để phát hiện lỗi'
help:
confidential: 'Ứng dụng sẽ được sử dụng khi client secret có thể được giữ bí mật. Ứng dụng di động native và SPA được coi là không bảo mật.'
redirect_uri: 'Dùng một dòng cho mỗi URI'
blank_redirect_uri: "Để trống nếu bạn đã cấu hình nhà cung cấp sử dụng Client Credentials, Resource Owner Password Credentials hoặc loại cấp khác không yêu cầu redirect URI."
scopes: 'Phân tách phạm vi bằng khoảng trắng. Để trống để dùng phạm vi mặc định.'
edit:
title: 'Chỉnh sửa ứng dụng'
index:
title: 'Ứng dụng của bạn'
new: 'Ứng dụng mới'
name: 'Tên'
callback_url: 'URL callback'
confidential: 'Bảo mật?'
actions: 'Hành động'
confidentiality:
'yes': 'Có'
'no': 'Không'
new:
title: 'Ứng dụng mới'
show:
title: 'Ứng dụng: %{name}'
application_id: 'UID'
secret: 'Bí mật'
secret_hashed: 'Bí mật đã mã hóa'
scopes: 'Phạm vi'
confidential: 'Bảo mật'
callback_urls: 'URL callback'
actions: 'Hành động'
not_defined: 'Chưa xác định'
authorizations:
buttons:
authorize: 'Ủy quyền'
deny: 'Từ chối'
error:
title: 'Đã xảy ra lỗi'
go_back: 'Quay lại'
new:
title: 'Yêu cầu ủy quyền'
prompt: 'Ủy quyền cho %{client_name} sử dụng tài khoản của bạn?'
able_to: 'Ứng dụng này sẽ có thể'
show:
title: 'Mã ủy quyền'
authorization_code_label: 'Mã ủy quyền:'
copy_instructions: 'Sao chép mã này và dán vào ứng dụng.'
form_post:
title: 'Gửi biểu mẫu này'
authorized_applications:
confirmations:
revoke: 'Bạn có chắc không?'
buttons:
revoke: 'Thu hồi'
index:
title: 'Ứng dụng đã được ủy quyền'
application: 'Ứng dụng'
created_at: 'Ngày tạo'
date_format: '%d-%m-%Y %H:%M:%S'
pre_authorization:
status: 'Tiền ủy quyền'
errors:
messages:
invalid_request:
unknown: 'Yêu cầu thiếu tham số bắt buộc, bao gồm giá trị tham số không được hỗ trợ, hoặc có định dạng sai.'
missing_param: 'Thiếu tham số bắt buộc: %{value}.'
request_not_authorized: 'Yêu cầu cần được ủy quyền. Tham số bắt buộc để ủy quyền yêu cầu bị thiếu hoặc không hợp lệ.'
invalid_code_challenge: 'Cần có code challenge.'
invalid_redirect_uri: "URI chuyển hướng được yêu cầu không hợp lệ hoặc không khớp với URI chuyển hướng của client."
unauthorized_client: 'Client không được phép thực hiện yêu cầu này bằng phương thức này.'
access_denied: 'Chủ sở hữu tài nguyên hoặc máy chủ ủy quyền đã từ chối yêu cầu.'
invalid_scope: 'Phạm vi được yêu cầu không hợp lệ, không xác định, hoặc có định dạng sai.'
invalid_code_challenge_method:
zero: 'Máy chủ ủy quyền không hỗ trợ PKCE vì không có giá trị code_challenge_method được chấp nhận.'
one: 'code_challenge_method phải là %{challenge_methods}.'
other: 'code_challenge_method phải là một trong %{challenge_methods}.'
server_error: 'Máy chủ ủy quyền gặp điều kiện không mong muốn ngăn không thể thực hiện yêu cầu.'
temporarily_unavailable: 'Máy chủ ủy quyền hiện không thể xử lý yêu cầu do quá tải tạm thời hoặc bảo trì.'
credential_flow_not_configured: 'Luồng Resource Owner Password Credentials thất bại do Doorkeeper.configure.resource_owner_from_credentials chưa được cấu hình.'
resource_owner_authenticator_not_configured: 'Tìm Resource Owner thất bại do Doorkeeper.configure.resource_owner_authenticator chưa được cấu hình.'
admin_authenticator_not_configured: 'Truy cập bảng quản trị bị cấm do Doorkeeper.configure.admin_authenticator chưa được cấu hình.'
unsupported_response_type: 'Máy chủ ủy quyền không hỗ trợ loại phản hồi này.'
unsupported_response_mode: 'Máy chủ ủy quyền không hỗ trợ chế độ phản hồi này.'
invalid_client: 'Xác thực client thất bại do client không xác định, không có xác thực client, hoặc phương thức xác thực không được hỗ trợ.'
invalid_grant: 'Authorization grant được cung cấp không hợp lệ, đã hết hạn, bị thu hồi, không khớp với URI chuyển hướng được dùng trong yêu cầu ủy quyền, hoặc được cấp cho client khác.'
unsupported_grant_type: 'Loại authorization grant không được máy chủ ủy quyền hỗ trợ.'
invalid_token:
revoked: "Access token đã bị thu hồi"
expired: "Access token đã hết hạn"
unknown: "Access token không hợp lệ"
revoke:
unauthorized: "Bạn không được phép thu hồi token này"
forbidden_token:
missing_scope: 'Truy cập tài nguyên này yêu cầu phạm vi "%{oauth_scopes}".'
flash:
applications:
create:
notice: 'Đã tạo ứng dụng.'
destroy:
notice: 'Đã xóa ứng dụng.'
update:
notice: 'Đã cập nhật ứng dụng.'
authorized_applications:
destroy:
notice: 'Đã thu hồi ứng dụng.'
layouts:
admin:
title: 'Doorkeeper'
nav:
oauth2_provider: 'Nhà cung cấp OAuth2'
applications: 'Ứng dụng'
home: 'Trang chủ'
application:
title: 'Yêu cầu ủy quyền OAuth'

View File

@@ -31,7 +31,7 @@ zh-CN:
edit:
title: 编辑应用
form:
error: 错误!请检查表单中可能错误
error: 错误!请检查表单中可能存在的错误
help:
blank_redirect_uri: 如果您的提供商配置为使用客户端凭据、资源所有者密码凭据或其他不需要重定向 URI 的授权类型,请留空。
confidential: 此应用将用于可以保密客户端密钥的场景。原生移动应用和单页应用被视为非保密应用。
@@ -64,6 +64,7 @@ zh-CN:
authorize: 授权
deny: 拒绝
error:
go_back: 返回
title: 发生错误
form_post:
title: 提交此表单
@@ -72,6 +73,8 @@ zh-CN:
prompt: 授权 %{client_name} 使用您的账户吗?
title: 需要授权
show:
authorization_code_label: 授权码:
copy_instructions: 复制此代码并粘贴到应用中。
title: 授权代码
authorized_applications:
buttons:
@@ -86,31 +89,28 @@ zh-CN:
errors:
messages:
access_denied: 资源所有者或授权服务器拒绝了请求。
admin_authenticator_not_configured: 访问管理面板被禁止,因为 Doorkeeper.configure.admin_authenticator
未配置。
credential_flow_not_configured: 资源所有者密码凭据流程失败,因为 Doorkeeper.configure.resource_owner_from_credentials
未配置。
admin_authenticator_not_configured: 访问管理面板被禁止,因为 Doorkeeper.configure.admin_authenticator 未配置。
credential_flow_not_configured: 资源所有者密码凭据流程失败,因为 Doorkeeper.configure.resource_owner_from_credentials 未配置。
forbidden_token:
missing_scope: 访问此资源需要权限范围 "%{oauth_scopes}"。
invalid_client: 客户端认证失败:未知客户端、未包含客户端认证或使用了不受支持的认证方法。
missing_scope: 访问此资源需要范围 "%{oauth_scopes}"。
invalid_client: 客户端认证失败:未知客户端、未包含客户端认证或使用了不受支持的认证方法。
invalid_code_challenge_method:
one: code_challenge_method 必须是 %{challenge_methods}。
other: code_challenge_method 必须是以下之一:%{challenge_methods}。
zero: 授权服务器不支持 PKCE因为没有可接受的 code_challenge_method 值。
invalid_grant: 提供的授权授权码无效、已过期、已撤销与授权请求中使用的重定向 URI 不匹配,或已颁发给其他客户端。
invalid_redirect_uri: 请求的重定向 URI 格式不正确或与客户端重定向 URI 不匹配。
invalid_grant: 提供的授权许可无效、已过期、已撤销,或与授权请求中使用的重定向 URI 不匹配,或已颁发给其他客户端。
invalid_redirect_uri: 请求的重定向 URI 格式不正确或与客户端重定向 URI 不匹配。
invalid_request:
invalid_code_challenge: 需要代码挑战参数
missing_param: 缺少必参数:%{value}。
request_not_authorized: 请求需要授权。授权请求的必要参数缺失或无效。
unknown: 请求缺少必参数、包含不受支持的参数值格式不正确。
invalid_scope: 请求的权限范围无效、未知或格式不正确。
invalid_code_challenge: 需要代码挑战。
missing_param: 缺少必参数:%{value}。
request_not_authorized: 请求需要授权。授权请求所需的参数缺失或无效。
unknown: 请求缺少必参数、包含不受支持的参数值,或以其他方式格式不正确。
invalid_scope: 请求的范围无效、未知或格式不正确。
invalid_token:
expired: 访问令牌已过期
revoked: 访问令牌已被撤销
unknown: 访问令牌无效
resource_owner_authenticator_not_configured: 资源所有者查找失败,因为 Doorkeeper.configure.resource_owner_authenticator
未配置。
resource_owner_authenticator_not_configured: 资源所有者查找失败,因为 Doorkeeper.configure.resource_owner_authenticator 未配置。
revoke:
unauthorized: 您无权撤销此令牌
server_error: 授权服务器遇到意外情况,无法完成请求。
@@ -133,9 +133,9 @@ zh-CN:
layouts:
admin:
nav:
applications: 应用管理
home:
oauth2_provider: OAuth2 提供
applications: 应用
home:
oauth2_provider: OAuth2 提供
title: Doorkeeper
application:
title: 需要 OAuth 授权

View File

@@ -0,0 +1,5 @@
---
vi:
invitation_mailer:
invite_email:
subject: "%{inviter} đã mời bạn tham gia hộ gia đình của họ trên %{product_name}!"

View File

@@ -0,0 +1,5 @@
---
vi:
pdf_import_mailer:
next_steps:
subject: "Tài liệu PDF của bạn đã được phân tích - %{product_name}"

View File

@@ -0,0 +1,5 @@
---
zh-CN:
pdf_import_mailer:
next_steps:
subject: "您的 PDF 文档已被分析 - %{product_name}"

View File

@@ -0,0 +1,34 @@
---
vi:
account_order:
balance_asc:
label: Số dư (Thấp đến Cao)
label_short: Số dư ↑
balance_desc:
label: Số dư (Cao đến Thấp)
label_short: Số dư ↓
name_asc:
label: Tên (A-Z)
label_short: Tên ↑
name_desc:
label: Tên (Z-A)
label_short: Tên ↓
activerecord:
attributes:
account:
balance: Số dư
currency: Tiền tệ
family: "%{moniker}"
family_id: "%{moniker}"
name: Tên
subtype: Loại phụ
models:
account: Tài khoản
account/credit: Thẻ tín dụng
account/depository: Tài khoản ngân hàng
account/investment: Đầu tư
account/loan: Khoản vay
account/other_asset: Tài sản khác
account/other_liability: Nợ khác
account/property: Bất động sản
account/vehicle: Phương tiện

View File

@@ -0,0 +1,30 @@
---
vi:
activerecord:
attributes:
account_statement:
account: Tài khoản
account_last4_hint: Bốn số cuối tài khoản
account_name_hint: Gợi ý tên tài khoản
closing_balance: Số dư đóng kỳ
content_sha256: Mã kiểm tra nội dung
currency: Tiền tệ
filename: Tên tệp
institution_name_hint: Gợi ý tổ chức
opening_balance: Số dư mở kỳ
original_file: Tệp sao kê
period_end_on: Ngày kết thúc kỳ
period_start_on: Ngày bắt đầu kỳ
errors:
models:
account_statement:
attributes:
checksum:
duplicate_statement_file: đã được tải lên cho hộ gia đình này
content_sha256:
duplicate_statement_file: đã được tải lên cho hộ gia đình này
original_file:
invalid_format: phải là tệp PDF, CSV hoặc XLSX
too_large: quá lớn. Kích thước tối đa là %{max_mb}MB
period_end_on:
on_or_after_start: phải từ ngày bắt đầu kỳ trở đi

View File

@@ -0,0 +1,30 @@
---
zh-CN:
activerecord:
attributes:
account_statement:
account: 账户
account_last4_hint: 账户尾四位
account_name_hint: 账户名称提示
closing_balance: 期末余额
content_sha256: 内容摘要
currency: 货币
filename: 文件名
institution_name_hint: 机构提示
opening_balance: 期初余额
original_file: 对账单文件
period_end_on: 周期结束
period_start_on: 周期开始
errors:
models:
account_statement:
attributes:
checksum:
duplicate_statement_file: 该家庭已上传过此文件
content_sha256:
duplicate_statement_file: 该家庭已上传过此文件
original_file:
invalid_format: 必须是 PDF、CSV 或 XLSX 文件
too_large: 文件过大。最大大小为 %{max_mb}MB
period_end_on:
on_or_after_start: 必须晚于或等于周期开始日期

View File

@@ -0,0 +1,11 @@
---
vi:
address:
attributes:
country: Quốc gia
line1: Địa chỉ dòng 1
line2: Địa chỉ dòng 2
locality: Địa phương
postal_code: Mã bưu chính
region: Vùng
format: "%{line1} %{line2}, %{locality}, %{region} %{postal_code} %{country}"

View File

@@ -0,0 +1,7 @@
---
vi:
activerecord:
errors:
models:
api_key:
cannot_destroy_demo_key: "Không thể xóa khóa API theo dõi demo"

View File

@@ -0,0 +1,7 @@
---
zh-CN:
activerecord:
errors:
models:
api_key:
cannot_destroy_demo_key: 不能删除演示监控 API 密钥

View File

@@ -0,0 +1,14 @@
---
vi:
activerecord:
attributes:
brex_item:
base_url: URL cơ sở
name: Tên kết nối
token: Token
errors:
models:
brex_item:
attributes:
base_url:
official_hosts_only: phải để trống, https://api.brex.com, hoặc https://api-staging.brex.com

View File

@@ -0,0 +1,14 @@
---
zh-CN:
activerecord:
attributes:
brex_item:
base_url: Base URL
name: 连接名称
token: Token
errors:
models:
brex_item:
attributes:
base_url:
official_hosts_only: 必须为空,或是 https://api.brex.com或是 https://api-staging.brex.com

View File

@@ -0,0 +1,29 @@
---
vi:
models:
category:
uncategorized: Chưa phân loại
other_investments: Đầu tư khác
investment_contributions: Đóng góp đầu tư
defaults:
income: Thu nhập
food_and_drink: Ăn uống
groceries: Thực phẩm
shopping: Mua sắm
transportation: Giao thông
travel: Du lịch
entertainment: Giải trí
healthcare: Chăm sóc sức khỏe
personal_care: Chăm sóc cá nhân
home_improvement: Cải thiện nhà ở
mortgage_rent: Thế chấp / Thuê nhà
utilities: Tiện ích
subscriptions: Đăng ký dịch vụ
insurance: Bảo hiểm
sports_and_fitness: Thể thao & Thể hình
gifts_and_donations: Quà tặng & Từ thiện
taxes: Thuế
loan_payments: Thanh toán khoản vay
services: Dịch vụ
fees: Phí
savings_and_investments: Tiết kiệm & Đầu tư

View File

@@ -0,0 +1,29 @@
---
zh-CN:
models:
category:
uncategorized: 未分类
other_investments: 其他投资
investment_contributions: 投资投入
defaults:
income: 收入
food_and_drink: 餐饮
groceries: 杂货
shopping: 购物
transportation: 交通
travel: 旅行
entertainment: 娱乐
healthcare: 医疗保健
personal_care: 个人护理
home_improvement: 家居改善
mortgage_rent: 房贷 / 房租
utilities: 公用事业
subscriptions: 订阅
insurance: 保险
sports_and_fitness: 运动与健身
gifts_and_donations: 礼物与捐赠
taxes: 税费
loan_payments: 贷款还款
services: 服务
fees: 费用
savings_and_investments: 储蓄与投资

View File

@@ -0,0 +1,8 @@
---
vi:
activerecord:
errors:
models:
category_import:
own_parent: "Danh mục '%{name}' không thể là danh mục cha của chính nó"
missing_columns: "Thiếu các cột bắt buộc: %{columns}"

View File

@@ -0,0 +1,8 @@
---
zh-CN:
activerecord:
errors:
models:
category_import:
own_parent: "分类 '%{name}' 不能是其自身的上级分类"
missing_columns: "缺少必需列:%{columns}"

View File

@@ -0,0 +1,8 @@
---
vi:
chat:
errors:
rate_limited: "Nhà cung cấp AI đang bị giới hạn tốc độ. Vui lòng thử lại sau vài phút."
temporarily_unavailable: "Nhà cung cấp AI tạm thời không khả dụng. Vui lòng thử lại sau vài phút."
misconfigured: "Nhà cung cấp AI chưa được cấu hình đúng. Vui lòng liên hệ quản trị viên."
default: "Không thể tạo phản hồi. Vui lòng thử lại."

View File

@@ -0,0 +1,8 @@
---
zh-CN:
chat:
errors:
rate_limited: AI 提供商当前已触发限流,请几分钟后再试。
temporarily_unavailable: AI 提供商当前暂时不可用,请几分钟后再试。
misconfigured: AI 提供商配置不正确,请联系您的管理员。
default: 生成回复失败,请重试。

View File

@@ -0,0 +1,5 @@
---
vi:
coinbase:
processor:
paid_via: "Thanh toán qua %{method}"

View File

@@ -0,0 +1,5 @@
---
zh-CN:
coinbase:
processor:
paid_via: "通过 %{method} 付款"

View File

@@ -0,0 +1,10 @@
---
vi:
models:
coinstats_item:
syncer:
importing_wallets: Đang nhập tài khoản tiền mã hóa từ CoinStats...
checking_configuration: Đang kiểm tra cấu hình tài khoản CoinStats...
wallets_need_setup: "%{count} tài khoản tiền mã hóa cần thiết lập..."
processing_holdings: Đang xử lý danh mục nắm giữ...
calculating_balances: Đang tính số dư...

View File

@@ -0,0 +1,10 @@
---
zh-CN:
models:
coinstats_item:
syncer:
importing_wallets: 正在从 CoinStats 导入加密账户...
checking_configuration: 正在检查 CoinStats 账户配置...
wallets_need_setup: "%{count} 个加密账户需要设置..."
processing_holdings: 正在处理持仓...
calculating_balances: 正在计算余额...

View File

@@ -0,0 +1,9 @@
---
vi:
activerecord:
errors:
models:
entry:
attributes:
base:
invalid_sell_quantity: không thể bán %{sell_qty} cổ phiếu %{ticker} vì bạn chỉ đang nắm giữ %{current_qty} cổ phiếu

View File

@@ -0,0 +1,18 @@
---
vi:
activerecord:
attributes:
import:
col_sep: Dấu phân cách cột
col_seps:
comma: Dấu phẩy (,)
semicolon: Dấu chấm phẩy (;)
currency: Tiền tệ
number_format: Định dạng số
errors:
models:
import:
duplicate_headers: "Tiêu đề CSV chuẩn hóa thành các cột trùng lặp: %{columns}"
attributes:
raw_file_str:
invalid_csv_format: không phải định dạng CSV hợp lệ

View File

@@ -0,0 +1,7 @@
---
vi:
activerecord:
errors:
models:
indexa_capital_item:
credentials_required: "Cần có biến môi trường INDEXA_API_TOKEN hoặc thông tin đăng nhập username/document/password"

View File

@@ -0,0 +1,7 @@
---
zh-CN:
activerecord:
errors:
models:
indexa_capital_item:
credentials_required: 必须提供 INDEXA_API_TOKEN 环境变量,或用户名/文档/密码凭据

View File

@@ -0,0 +1,54 @@
---
vi:
period:
last_day:
label_short: "1N"
label: "Hôm qua"
comparison_label: "so với hôm qua"
current_week:
label_short: "TNN"
label: "Tuần này"
comparison_label: "so với đầu tuần"
last_7_days:
label_short: "7N"
label: "7 Ngày Qua"
comparison_label: "so với tuần trước"
current_month:
label_short: "TTN"
label: "Tháng này"
comparison_label: "so với đầu tháng"
last_month:
label_short: "TT"
label: "Tháng Trước"
comparison_label: "so với tháng trước"
last_30_days:
label_short: "30N"
label: "30 Ngày Qua"
comparison_label: "so với 30 ngày trước"
last_90_days:
label_short: "90N"
label: "90 Ngày Qua"
comparison_label: "so với quý trước"
current_year:
label_short: "NĐN"
label: "Năm này"
comparison_label: "so với đầu năm"
last_365_days:
label_short: "365N"
label: "365 Ngày Qua"
comparison_label: "so với 1 năm trước"
last_5_years:
label_short: "5Năm"
label: "5 Năm Qua"
comparison_label: "so với 5 năm trước"
last_10_years:
label_short: "10Năm"
label: "10 Năm Qua"
comparison_label: "so với 10 năm trước"
all_time:
label_short: "Tất cả"
label: "Toàn thời gian"
comparison_label: "so với ban đầu"
custom:
label_short: "Tùy chỉnh"
label: "Khoảng thời gian tùy chỉnh"

View File

@@ -0,0 +1,54 @@
---
zh-CN:
period:
last_day:
label_short: "1D"
label: "最近 1 天"
comparison_label: "与昨天相比"
current_week:
label_short: "WTD"
label: "本周"
comparison_label: "与周初相比"
last_7_days:
label_short: "7D"
label: "最近 7 天"
comparison_label: "与上周相比"
current_month:
label_short: "MTD"
label: "本月"
comparison_label: "与月初相比"
last_month:
label_short: "LM"
label: "上月"
comparison_label: "与上月相比"
last_30_days:
label_short: "30D"
label: "最近 30 天"
comparison_label: "与最近 30 天相比"
last_90_days:
label_short: "90D"
label: "最近 90 天"
comparison_label: "与上个季度相比"
current_year:
label_short: "YTD"
label: "本年"
comparison_label: "与年初相比"
last_365_days:
label_short: "365D"
label: "最近 365 天"
comparison_label: "与 1 年前相比"
last_5_years:
label_short: "5Y"
label: "最近 5 年"
comparison_label: "与 5 年前相比"
last_10_years:
label_short: "10Y"
label: "最近 10 年"
comparison_label: "与 10 年前相比"
all_time:
label_short: "All"
label: "全部时间"
comparison_label: "与起始时间相比"
custom:
label_short: "自定义"
label: "自定义周期"

View File

@@ -0,0 +1,7 @@
---
vi:
activerecord:
errors:
models:
plaid_account:
no_balance: "Tài khoản Plaid phải có số dư hiện tại hoặc số dư khả dụng"

View File

@@ -0,0 +1,7 @@
---
zh-CN:
activerecord:
errors:
models:
plaid_account:
no_balance: Plaid 账户必须具有“当前余额”或“可用余额”之一

View File

@@ -0,0 +1,4 @@
---
vi:
provider_warnings:
limited_investment_data: "Dữ liệu đầu tư từ nhà cung cấp này còn hạn chế. Nhãn hoạt động (Mua, Bán, Cổ tức) không khả dụng, có thể ảnh hưởng đến độ chính xác của ngân sách. Hãy cân nhắc tạo quy tắc để loại trừ hoặc phân loại các giao dịch đầu tư."

View File

@@ -0,0 +1,7 @@
---
vi:
activerecord:
errors:
models:
recurring_transaction:
merchant_or_name_required: "Phải có nhà cung cấp hoặc tên"

View File

@@ -0,0 +1,7 @@
---
zh-CN:
activerecord:
errors:
models:
recurring_transaction:
merchant_or_name_required: 商户或名称至少必须存在一个

View File

@@ -0,0 +1,9 @@
---
vi:
activerecord:
errors:
models:
rule:
min_actions: "phải có ít nhất một hành động"
duplicate_actions: "Quy tắc không được có các hành động trùng lặp %{types}"
nested_conditions: "Điều kiện phức hợp không thể lồng nhau"

View File

@@ -0,0 +1,9 @@
---
zh-CN:
activerecord:
errors:
models:
rule:
min_actions: 至少必须有一个动作
duplicate_actions: 规则不能有重复动作 %{types}
nested_conditions: 复合条件不能嵌套

View File

@@ -0,0 +1,9 @@
---
vi:
activerecord:
errors:
models:
rule_import:
unsupported_resource_type: "Loại tài nguyên không được hỗ trợ: %{resource_type}"
invalid_json: "JSON không hợp lệ trong điều kiện hoặc hành động: %{message}"
min_actions: "Quy tắc phải có ít nhất một hành động"

View File

@@ -0,0 +1,9 @@
---
zh-CN:
activerecord:
errors:
models:
rule_import:
unsupported_resource_type: "不支持的资源类型:%{resource_type}"
invalid_json: "conditions 或 actions 中的 JSON 无效:%{message}"
min_actions: 规则至少必须有一个动作

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