mirror of
https://github.com/we-promise/sure.git
synced 2026-05-31 08:19:03 +00:00
Merge branch 'feat/retirement-v2-preview' into feat/retirement-v2-data
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
60
.github/workflows/label-not-gittensor.yml
vendored
60
.github/workflows/label-not-gittensor.yml
vendored
@@ -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}.`);
|
||||
114
.github/workflows/preview-deploy.yml
vendored
114
.github/workflows/preview-deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:)
|
||||
|
||||
@@ -27,6 +27,7 @@ module Assistant
|
||||
Function::GetHoldings,
|
||||
Function::GetBalanceSheet,
|
||||
Function::GetIncomeStatement,
|
||||
Function::GetBudget,
|
||||
Function::ImportBankStatement,
|
||||
Function::SearchFamilyFiles,
|
||||
Function::CreateGoal
|
||||
|
||||
200
app/models/assistant/function/get_budget.rb
Normal file
200
app/models/assistant/function/get_budget.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
45
app/models/binance_item/futures_importer.rb
Normal file
45
app/models/binance_item/futures_importer.rb
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
{
|
||||
|
||||
91
app/models/category/merger.rb
Normal file
91
app/models/category/merger.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
app/views/categories/merge.html.erb
Normal file
30
app/views/categories/merge.html.erb
Normal 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 %>
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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? }
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 %>">
|
||||
|
||||
@@ -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 %>">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %>">
|
||||
|
||||
@@ -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 %>">
|
||||
|
||||
136
bin/preview_deploy_security_check.rb
Normal file
136
bin/preview_deploy_security_check.rb
Normal 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"
|
||||
@@ -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: "Ð"
|
||||
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
|
||||
|
||||
87
config/locales/breadcrumbs/vi.yml
Normal file
87
config/locales/breadcrumbs/vi.yml
Normal 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
|
||||
@@ -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: 车辆
|
||||
|
||||
@@ -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:
|
||||
|
||||
149
config/locales/doorkeeper.vi.yml
Normal file
149
config/locales/doorkeeper.vi.yml
Normal 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'
|
||||
@@ -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 授权
|
||||
|
||||
5
config/locales/mailers/invitation_mailer/vi.yml
Normal file
5
config/locales/mailers/invitation_mailer/vi.yml
Normal 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}!"
|
||||
5
config/locales/mailers/pdf_import_mailer/vi.yml
Normal file
5
config/locales/mailers/pdf_import_mailer/vi.yml
Normal 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}"
|
||||
5
config/locales/mailers/pdf_import_mailer/zh-CN.yml
Normal file
5
config/locales/mailers/pdf_import_mailer/zh-CN.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
zh-CN:
|
||||
pdf_import_mailer:
|
||||
next_steps:
|
||||
subject: "您的 PDF 文档已被分析 - %{product_name}"
|
||||
34
config/locales/models/account/vi.yml
Normal file
34
config/locales/models/account/vi.yml
Normal 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
|
||||
30
config/locales/models/account_statement/vi.yml
Normal file
30
config/locales/models/account_statement/vi.yml
Normal 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
|
||||
30
config/locales/models/account_statement/zh-CN.yml
Normal file
30
config/locales/models/account_statement/zh-CN.yml
Normal 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: 必须晚于或等于周期开始日期
|
||||
11
config/locales/models/address/vi.yml
Normal file
11
config/locales/models/address/vi.yml
Normal 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}"
|
||||
7
config/locales/models/api_key/vi.yml
Normal file
7
config/locales/models/api_key/vi.yml
Normal 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"
|
||||
7
config/locales/models/api_key/zh-CN.yml
Normal file
7
config/locales/models/api_key/zh-CN.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
zh-CN:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
api_key:
|
||||
cannot_destroy_demo_key: 不能删除演示监控 API 密钥
|
||||
14
config/locales/models/brex_item/vi.yml
Normal file
14
config/locales/models/brex_item/vi.yml
Normal 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
|
||||
14
config/locales/models/brex_item/zh-CN.yml
Normal file
14
config/locales/models/brex_item/zh-CN.yml
Normal 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
|
||||
29
config/locales/models/category/vi.yml
Normal file
29
config/locales/models/category/vi.yml
Normal 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ư
|
||||
29
config/locales/models/category/zh-CN.yml
Normal file
29
config/locales/models/category/zh-CN.yml
Normal 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: 储蓄与投资
|
||||
8
config/locales/models/category_import/vi.yml
Normal file
8
config/locales/models/category_import/vi.yml
Normal 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}"
|
||||
8
config/locales/models/category_import/zh-CN.yml
Normal file
8
config/locales/models/category_import/zh-CN.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
zh-CN:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
category_import:
|
||||
own_parent: "分类 '%{name}' 不能是其自身的上级分类"
|
||||
missing_columns: "缺少必需列:%{columns}"
|
||||
8
config/locales/models/chat/vi.yml
Normal file
8
config/locales/models/chat/vi.yml
Normal 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."
|
||||
8
config/locales/models/chat/zh-CN.yml
Normal file
8
config/locales/models/chat/zh-CN.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
zh-CN:
|
||||
chat:
|
||||
errors:
|
||||
rate_limited: AI 提供商当前已触发限流,请几分钟后再试。
|
||||
temporarily_unavailable: AI 提供商当前暂时不可用,请几分钟后再试。
|
||||
misconfigured: AI 提供商配置不正确,请联系您的管理员。
|
||||
default: 生成回复失败,请重试。
|
||||
5
config/locales/models/coinbase_account/vi.yml
Normal file
5
config/locales/models/coinbase_account/vi.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
vi:
|
||||
coinbase:
|
||||
processor:
|
||||
paid_via: "Thanh toán qua %{method}"
|
||||
5
config/locales/models/coinbase_account/zh-CN.yml
Normal file
5
config/locales/models/coinbase_account/zh-CN.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
zh-CN:
|
||||
coinbase:
|
||||
processor:
|
||||
paid_via: "通过 %{method} 付款"
|
||||
10
config/locales/models/coinstats_item/vi.yml
Normal file
10
config/locales/models/coinstats_item/vi.yml
Normal 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ư...
|
||||
10
config/locales/models/coinstats_item/zh-CN.yml
Normal file
10
config/locales/models/coinstats_item/zh-CN.yml
Normal 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: 正在计算余额...
|
||||
9
config/locales/models/entry/vi.yml
Normal file
9
config/locales/models/entry/vi.yml
Normal 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
|
||||
18
config/locales/models/import/vi.yml
Normal file
18
config/locales/models/import/vi.yml
Normal 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ệ
|
||||
7
config/locales/models/indexa_capital_item/vi.yml
Normal file
7
config/locales/models/indexa_capital_item/vi.yml
Normal 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"
|
||||
7
config/locales/models/indexa_capital_item/zh-CN.yml
Normal file
7
config/locales/models/indexa_capital_item/zh-CN.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
zh-CN:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
indexa_capital_item:
|
||||
credentials_required: 必须提供 INDEXA_API_TOKEN 环境变量,或用户名/文档/密码凭据
|
||||
54
config/locales/models/period/vi.yml
Normal file
54
config/locales/models/period/vi.yml
Normal 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"
|
||||
54
config/locales/models/period/zh-CN.yml
Normal file
54
config/locales/models/period/zh-CN.yml
Normal 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: "自定义周期"
|
||||
7
config/locales/models/plaid_account/vi.yml
Normal file
7
config/locales/models/plaid_account/vi.yml
Normal 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"
|
||||
7
config/locales/models/plaid_account/zh-CN.yml
Normal file
7
config/locales/models/plaid_account/zh-CN.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
zh-CN:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
plaid_account:
|
||||
no_balance: Plaid 账户必须具有“当前余额”或“可用余额”之一
|
||||
4
config/locales/models/provider_warnings/vi.yml
Normal file
4
config/locales/models/provider_warnings/vi.yml
Normal 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ư."
|
||||
7
config/locales/models/recurring_transaction/vi.yml
Normal file
7
config/locales/models/recurring_transaction/vi.yml
Normal 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"
|
||||
7
config/locales/models/recurring_transaction/zh-CN.yml
Normal file
7
config/locales/models/recurring_transaction/zh-CN.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
zh-CN:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
recurring_transaction:
|
||||
merchant_or_name_required: 商户或名称至少必须存在一个
|
||||
9
config/locales/models/rule/vi.yml
Normal file
9
config/locales/models/rule/vi.yml
Normal 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"
|
||||
9
config/locales/models/rule/zh-CN.yml
Normal file
9
config/locales/models/rule/zh-CN.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
zh-CN:
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
rule:
|
||||
min_actions: 至少必须有一个动作
|
||||
duplicate_actions: 规则不能有重复动作 %{types}
|
||||
nested_conditions: 复合条件不能嵌套
|
||||
9
config/locales/models/rule_import/vi.yml
Normal file
9
config/locales/models/rule_import/vi.yml
Normal 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"
|
||||
9
config/locales/models/rule_import/zh-CN.yml
Normal file
9
config/locales/models/rule_import/zh-CN.yml
Normal 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
Reference in New Issue
Block a user