fix: locale-dependent category duplication bug (#956)

* fix: locale-dependent category duplication bug

* fix: use family locale for investment contributions category to prevent duplicates and handle legacy data

* Remove v* tag trigger from flutter-build to fix double-runs

publish.yml already calls flutter-build via workflow_call on v* tags,
so the direct push trigger was causing duplicate workflow runs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Refactor mobile release asset flow

* fix: category uniqueness and workflow issues

* fix: fix test issue

* fix: solve test issue

* fix: resolve legacy problem

* fix: solve lint test issue

* fix: revert unrelated changes

---------

Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BitToby
2026-02-15 06:33:51 -03:00
committed by GitHub
parent 326b925690
commit e573896efe
8 changed files with 185 additions and 12 deletions

View File

@@ -179,4 +179,4 @@ jobs:
path: | path: |
mobile/build/ios/iphoneos/Runner.app mobile/build/ios/iphoneos/Runner.app
mobile/build/ios-build-info.txt mobile/build/ios-build-info.txt
retention-days: 30 retention-days: 30

View File

@@ -111,4 +111,4 @@ jobs:
- **Android APK**: Debug build for testing on Android devices - **Android APK**: Debug build for testing on Android devices
- **iOS Build**: Unsigned iOS build (requires code signing for installation) - **iOS Build**: Unsigned iOS build (requires code signing for installation)
> **Note**: These are builds intended for testing purposes. For production use, please build from source with proper signing credentials. > **Note**: These are builds intended for testing purposes. For production use, please build from source with proper signing credentials.

View File

@@ -465,4 +465,4 @@ jobs:
echo "Push failed (attempt $attempts). Retrying in ${delay} seconds..." echo "Push failed (attempt $attempts). Retrying in ${delay} seconds..."
sleep ${delay} sleep ${delay}
git pull --rebase origin $SOURCE_BRANCH git pull --rebase origin $SOURCE_BRANCH
done done

View File

@@ -127,6 +127,14 @@ class Category < ApplicationRecord
I18n.t(INVESTMENT_CONTRIBUTIONS_NAME_KEY) I18n.t(INVESTMENT_CONTRIBUTIONS_NAME_KEY)
end end
# Returns all possible investment contributions names across all supported locales
# Used to detect investment contributions category regardless of locale
def all_investment_contributions_names
LanguagesHelper::SUPPORTED_LOCALES.map do |locale|
I18n.t(INVESTMENT_CONTRIBUTIONS_NAME_KEY, locale: locale)
end.uniq
end
private private
def default_categories def default_categories
[ [

View File

@@ -123,14 +123,45 @@ class Family < ApplicationRecord
# Returns the Investment Contributions category for this family, creating it if it doesn't exist. # Returns the Investment Contributions category for this family, creating it if it doesn't exist.
# This is used for auto-categorizing transfers to investment accounts. # This is used for auto-categorizing transfers to investment accounts.
# Always uses the family's locale to ensure consistent category naming across all users.
def investment_contributions_category def investment_contributions_category
categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat| # Find ALL legacy categories (created under old request-locale behavior)
cat.color = "#0d9488" legacy = categories.where(name: Category.all_investment_contributions_names).order(:created_at).to_a
cat.classification = "expense"
cat.lucide_icon = "trending-up" if legacy.any?
keeper = legacy.first
duplicates = legacy[1..]
# Reassign transactions and subcategories from duplicates to keeper
if duplicates.any?
duplicate_ids = duplicates.map(&:id)
categories.where(parent_id: duplicate_ids).update_all(parent_id: keeper.id)
Transaction.where(category_id: duplicate_ids).update_all(category_id: keeper.id)
BudgetCategory.where(category_id: duplicate_ids).update_all(category_id: keeper.id)
categories.where(id: duplicate_ids).delete_all
end
# Rename keeper to family's locale name if needed
I18n.with_locale(locale) do
correct_name = Category.investment_contributions_name
keeper.update!(name: correct_name) unless keeper.name == correct_name
end
return keeper
end
# Create new category using family's locale
I18n.with_locale(locale) do
categories.find_or_create_by!(name: Category.investment_contributions_name) do |cat|
cat.color = "#0d9488"
cat.classification = "expense"
cat.lucide_icon = "trending-up"
end
end end
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
categories.find_by(name: Category.investment_contributions_name) # Handle race condition: another process created the category
I18n.with_locale(locale) do
categories.find_by!(name: Category.investment_contributions_name)
end
end end
# Returns account IDs for tax-advantaged accounts (401k, IRA, HSA, etc.) # Returns account IDs for tax-advantaged accounts (401k, IRA, HSA, etc.)

View File

@@ -30,4 +30,14 @@ class CategoryTest < ActiveSupport::TestCase
assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message
end end
test "all_investment_contributions_names returns all locale variants" do
names = Category.all_investment_contributions_names
assert_includes names, "Investment Contributions" # English
assert_includes names, "Contributions aux investissements" # French
assert_includes names, "Investeringsbijdragen" # Dutch
assert names.all? { |name| name.is_a?(String) }
assert_equal names, names.uniq # No duplicates
end
end end

View File

@@ -36,6 +36,127 @@ class FamilyTest < ActiveSupport::TestCase
end end
end end
test "investment_contributions_category uses family locale consistently" do
family = families(:dylan_family)
family.update!(locale: "fr")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Simulate different request locales (e.g., from Accept-Language header)
# The category should always be created with the family's locale (French)
category_from_english_request = I18n.with_locale(:en) do
family.investment_contributions_category
end
assert_equal "Contributions aux investissements", category_from_english_request.name
# Second request with different locale should find the same category
assert_no_difference "Category.count" do
category_from_dutch_request = I18n.with_locale(:nl) do
family.investment_contributions_category
end
assert_equal category_from_english_request.id, category_from_dutch_request.id
assert_equal "Contributions aux investissements", category_from_dutch_request.name
end
end
test "investment_contributions_category prevents duplicate categories across locales" do
family = families(:dylan_family)
family.update!(locale: "en")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Create category under English family locale
english_category = family.investment_contributions_category
assert_equal "Investment Contributions", english_category.name
# Simulate a request with French locale (e.g., from browser Accept-Language)
# Should still return the English category, not create a French one
assert_no_difference "Category.count" do
I18n.with_locale(:fr) do
french_request_category = family.investment_contributions_category
assert_equal english_category.id, french_request_category.id
assert_equal "Investment Contributions", french_request_category.name
end
end
end
test "investment_contributions_category reuses legacy category with wrong locale" do
family = families(:dylan_family)
family.update!(locale: "fr")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Simulate legacy: category was created with English name (old bug behavior)
legacy_category = family.categories.create!(
name: "Investment Contributions",
color: "#0d9488",
classification: "expense",
lucide_icon: "trending-up"
)
# Should find and reuse the legacy category, updating its name to French
assert_no_difference "Category.count" do
result = family.investment_contributions_category
assert_equal legacy_category.id, result.id
assert_equal "Contributions aux investissements", result.name
end
end
test "investment_contributions_category merges multiple locale variants" do
family = families(:dylan_family)
family.update!(locale: "en")
family.categories.where(name: [ "Investment Contributions", "Contributions aux investissements" ]).destroy_all
# Simulate legacy: multiple categories created under different locales
english_category = family.categories.create!(
name: "Investment Contributions",
color: "#0d9488",
classification: "expense",
lucide_icon: "trending-up"
)
french_category = family.categories.create!(
name: "Contributions aux investissements",
color: "#0d9488",
classification: "expense",
lucide_icon: "trending-up"
)
# Create transactions pointing to both categories
account = family.accounts.first
txn1 = Transaction.create!(category: english_category)
Entry.create!(
account: account,
entryable: txn1,
amount: 100,
currency: "USD",
date: Date.current,
name: "Test 1"
)
txn2 = Transaction.create!(category: french_category)
Entry.create!(
account: account,
entryable: txn2,
amount: 200,
currency: "USD",
date: Date.current,
name: "Test 2"
)
# Should merge both categories into one, keeping the oldest
assert_difference "Category.count", -1 do
result = family.investment_contributions_category
assert_equal english_category.id, result.id
assert_equal "Investment Contributions", result.name
# Both transactions should now point to the keeper
assert_equal english_category.id, txn1.reload.category_id
assert_equal english_category.id, txn2.reload.category_id
# French category should be deleted
assert_nil Category.find_by(id: french_category.id)
end
end
test "moniker helpers return expected singular and plural labels" do test "moniker helpers return expected singular and plural labels" do
family = families(:dylan_family) family = families(:dylan_family)

View File

@@ -81,11 +81,14 @@ module ActiveSupport
# Ensures the Investment Contributions category exists for a family # Ensures the Investment Contributions category exists for a family
# Used in transfer tests where this bootstrapped category is required # Used in transfer tests where this bootstrapped category is required
# Uses family locale to ensure consistent naming
def ensure_investment_contributions_category(family) def ensure_investment_contributions_category(family)
family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c| I18n.with_locale(family.locale) do
c.color = "#0d9488" family.categories.find_or_create_by!(name: Category.investment_contributions_name) do |c|
c.lucide_icon = "trending-up" c.color = "#0d9488"
c.classification = "expense" c.lucide_icon = "trending-up"
c.classification = "expense"
end
end end
end end
end end