From ba0dd9056fbc60879372a02ad6361d752757fa9e Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 15 Jul 2026 19:40:03 +0100 Subject: [PATCH 1/5] fix(currencies): Offer every currency the app claims to support Three forms carried their own hardcoded currency lists instead of the shared one, so most of the currencies the app already knew about could not be picked anywhere. Someone could choose the rupee as their currency during onboarding and then find no way to open a wallet in it, because the wallet form offered nine currencies and that was not among them. The transaction and transfer forms build their lists from the wallets you hold, so a currency missing at the wallet could never appear there either. Every form now offers the shared list, which grows to cover the places people are actually paid: India, Brazil, Indonesia, the Philippines, Vietnam, Pakistan, Bangladesh, Turkey, Poland, Ukraine, Mexico and more. A wallet already holding something outside the list keeps working. --- components/TransactionForm.vue | 5 +- components/TransferForm.vue | 5 +- components/WalletForm.vue | 13 ++--- tests/components/walletFormCurrencies.test.ts | 41 +++++++++++++ utils/currencies.ts | 58 +++++++++++++++---- 5 files changed, 99 insertions(+), 23 deletions(-) create mode 100644 tests/components/walletFormCurrencies.test.ts diff --git a/components/TransactionForm.vue b/components/TransactionForm.vue index 9812cf2..1bdc179 100644 --- a/components/TransactionForm.vue +++ b/components/TransactionForm.vue @@ -290,6 +290,7 @@ import { Gift } from 'lucide-vue-next'; import { useSharedData } from '~/composables/useSharedData'; +import { CURRENCIES } from '@/utils/currencies'; import { fetchAllPages } from '~/services/api/apiHelpers'; import { api } from '@/services/api'; import type { TransactionFile, TransactionIntent } from '~/types/transaction'; @@ -461,7 +462,9 @@ const categories = computed(() => { }); const availableCurrencies = computed(() => { - const currencies = new Set(['XAF', 'USD', 'EUR', 'GBP', 'NGN']); + const currencies = new Set(CURRENCIES.map((c) => c.code)); + // A wallet may hold a currency the list does not carry; never hide it from + // the person whose money is already in it. sharedData.wallets.value.forEach((wallet) => { if (wallet.currency) { currencies.add(wallet.currency); diff --git a/components/TransferForm.vue b/components/TransferForm.vue index 1a34ebf..e8fc096 100644 --- a/components/TransferForm.vue +++ b/components/TransferForm.vue @@ -105,6 +105,7 @@ import TButton from './TButton.vue'; import SearchableDropdown from './SearchableDropdown.vue'; import { ArrowsRightLeftIcon } from '@heroicons/vue/24/outline'; import { useSharedData } from '~/composables/useSharedData'; +import { CURRENCIES } from '@/utils/currencies'; const { t } = useI18n(); @@ -138,7 +139,9 @@ const toWalletError = ref(false); const exchangeRateError = ref(false); const availableCurrencies = computed(() => { - const currencies = new Set(['XAF', 'USD', 'EUR', 'GBP', 'NGN']); + const currencies = new Set(CURRENCIES.map((c) => c.code)); + // A wallet may hold a currency the list does not carry; never hide it from + // the person whose money is already in it. sharedData.wallets.value.forEach((wallet) => { if (wallet.currency) { currencies.add(wallet.currency); diff --git a/components/WalletForm.vue b/components/WalletForm.vue index 427a228..2617c6c 100644 --- a/components/WalletForm.vue +++ b/components/WalletForm.vue @@ -75,15 +75,9 @@ required > - - - - - - - - - +
{{ t('Please select a currency.') }}
@@ -134,6 +128,7 @@ import IconPicker from './IconPicker.vue'; import * as lucideIcons from 'lucide-vue-next'; import { ImagePlus, X } from 'lucide-vue-next'; import { useSharedData } from '@/composables/useSharedData'; +import { CURRENCIES } from '@/utils/currencies'; const { t } = useI18n(); diff --git a/tests/components/walletFormCurrencies.test.ts b/tests/components/walletFormCurrencies.test.ts new file mode 100644 index 0000000..c7fc9f1 --- /dev/null +++ b/tests/components/walletFormCurrencies.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { ref } from 'vue'; +import WalletForm from '@/components/WalletForm.vue'; +import { CURRENCIES } from '@/utils/currencies'; + +vi.stubGlobal('useI18n', () => ({ t: (k: string) => k })); + +vi.mock('@/composables/useSharedData', () => ({ + useSharedData: () => ({ + wallets: ref([]), + groups: ref([]) + }) +})); + +const codes = () => + mount(WalletForm) + .findAll('#wallet-currency option') + .map((o) => o.attributes('value')) + .filter(Boolean); + +describe('WalletForm currency options', () => { + it('offers the currencies people actually hold, not a hand-picked few', () => { + const offered = codes(); + + // The wallet is the way into the app: a currency missing here can never + // reach the transaction or transfer forms either, since those build their + // list from the wallets you already have. + expect(offered).toContain('INR'); + expect(offered).toContain('BRL'); + expect(offered).toContain('IDR'); + expect(offered).toContain('NGN'); + expect(offered).toContain('USD'); + }); + + it('offers every currency the app claims to support', () => { + const offered = codes(); + + CURRENCIES.forEach((c) => expect(offered).toContain(c.code)); + }); +}); diff --git a/utils/currencies.ts b/utils/currencies.ts index 4d6bdd6..b870201 100644 --- a/utils/currencies.ts +++ b/utils/currencies.ts @@ -1,25 +1,59 @@ /** * Available currencies for the application * Centralized to ensure consistency across all components + * + * Ordered by code: these render in plain selects, where typing the code is how + * people find their own among fifty. */ export const CURRENCIES = [ - { code: 'USD', name: 'US Dollar', symbol: '$' }, + { code: 'AED', name: 'UAE Dirham', symbol: 'AED' }, + { code: 'ARS', name: 'Argentine Peso', symbol: '$' }, + { code: 'AUD', name: 'Australian Dollar', symbol: 'A$' }, + { code: 'BDT', name: 'Bangladeshi Taka', symbol: '৳' }, + { code: 'BRL', name: 'Brazilian Real', symbol: 'R$' }, + { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }, + { code: 'CHF', name: 'Swiss Franc', symbol: 'CHF' }, + { code: 'CLP', name: 'Chilean Peso', symbol: '$' }, + { code: 'CNY', name: 'Chinese Yuan', symbol: '¥' }, + { code: 'COP', name: 'Colombian Peso', symbol: '$' }, + { code: 'CZK', name: 'Czech Koruna', symbol: 'Kč' }, + { code: 'DKK', name: 'Danish Krone', symbol: 'kr' }, + { code: 'EGP', name: 'Egyptian Pound', symbol: 'E£' }, { code: 'EUR', name: 'Euro', symbol: '€' }, { code: 'GBP', name: 'British Pound', symbol: '£' }, - { code: 'JPY', name: 'Japanese Yen', symbol: '¥' }, - { code: 'CAD', name: 'Canadian Dollar', symbol: 'C$' }, - { code: 'AUD', name: 'Australian Dollar', symbol: 'A$' }, - { code: 'XAF', name: 'Central African CFA Franc', symbol: 'FCFA' }, - { code: 'XOF', name: 'West African CFA Franc', symbol: 'CFA' }, - { code: 'NGN', name: 'Nigerian Naira', symbol: '₦' }, { code: 'GHS', name: 'Ghanaian Cedi', symbol: '₵' }, - { code: 'KES', name: 'Kenyan Shilling', symbol: 'KSh' }, - { code: 'ZAR', name: 'South African Rand', symbol: 'R' }, + { code: 'HKD', name: 'Hong Kong Dollar', symbol: 'HK$' }, + { code: 'HUF', name: 'Hungarian Forint', symbol: 'Ft' }, + { code: 'IDR', name: 'Indonesian Rupiah', symbol: 'Rp' }, + { code: 'ILS', name: 'Israeli New Shekel', symbol: '₪' }, { code: 'INR', name: 'Indian Rupee', symbol: '₹' }, - { code: 'CNY', name: 'Chinese Yuan', symbol: '¥' }, - { code: 'BRL', name: 'Brazilian Real', symbol: 'R$' }, + { code: 'JPY', name: 'Japanese Yen', symbol: '¥' }, + { code: 'KES', name: 'Kenyan Shilling', symbol: 'KSh' }, + { code: 'KRW', name: 'South Korean Won', symbol: '₩' }, + { code: 'LKR', name: 'Sri Lankan Rupee', symbol: 'Rs' }, + { code: 'MAD', name: 'Moroccan Dirham', symbol: 'MAD' }, { code: 'MXN', name: 'Mexican Peso', symbol: '$' }, - { code: 'CHF', name: 'Swiss Franc', symbol: 'CHF' } + { code: 'MYR', name: 'Malaysian Ringgit', symbol: 'RM' }, + { code: 'NGN', name: 'Nigerian Naira', symbol: '₦' }, + { code: 'NOK', name: 'Norwegian Krone', symbol: 'kr' }, + { code: 'NZD', name: 'New Zealand Dollar', symbol: 'NZ$' }, + { code: 'PHP', name: 'Philippine Peso', symbol: '₱' }, + { code: 'PKR', name: 'Pakistani Rupee', symbol: 'Rs' }, + { code: 'PLN', name: 'Polish Zloty', symbol: 'zł' }, + { code: 'RON', name: 'Romanian Leu', symbol: 'lei' }, + { code: 'RUB', name: 'Russian Ruble', symbol: '₽' }, + { code: 'SAR', name: 'Saudi Riyal', symbol: 'SAR' }, + { code: 'SEK', name: 'Swedish Krona', symbol: 'kr' }, + { code: 'SGD', name: 'Singapore Dollar', symbol: 'S$' }, + { code: 'THB', name: 'Thai Baht', symbol: '฿' }, + { code: 'TRY', name: 'Turkish Lira', symbol: '₺' }, + { code: 'TWD', name: 'New Taiwan Dollar', symbol: 'NT$' }, + { code: 'UAH', name: 'Ukrainian Hryvnia', symbol: '₴' }, + { code: 'USD', name: 'US Dollar', symbol: '$' }, + { code: 'VND', name: 'Vietnamese Dong', symbol: '₫' }, + { code: 'XAF', name: 'Central African CFA Franc', symbol: 'FCFA' }, + { code: 'XOF', name: 'West African CFA Franc', symbol: 'CFA' }, + { code: 'ZAR', name: 'South African Rand', symbol: 'R' } ] as const; export type CurrencyCode = (typeof CURRENCIES)[number]['code']; From 125b8c6bf766b074aff941b1eabccf52dc06b3fc Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 15 Jul 2026 19:12:11 +0100 Subject: [PATCH 2/5] fix(transactions): Limit a transaction to one category A transaction could carry any number of categories, which made "what did I spend this on" ambiguous and let one amount count under several headings at once. Picking a category is now a single choice rather than a growing list of chips. Opening a transaction saved before the limit keeps the first category it had; the rest fall away when it is saved. --- components/TransactionForm.vue | 22 +++-- tests/components/TransactionForm.test.ts | 120 ++++++++++------------- 2 files changed, 67 insertions(+), 75 deletions(-) diff --git a/components/TransactionForm.vue b/components/TransactionForm.vue index 1bdc179..6456a08 100644 --- a/components/TransactionForm.vue +++ b/components/TransactionForm.vue @@ -120,11 +120,9 @@ @@ -345,7 +343,7 @@ const intentOptions = computed(() => { const selectedPartyId = ref(null); const selectedWalletId = ref(null); const selectedGroupId = ref(null); -const selectedAdditionalCategoryIds = ref([]); +const selectedCategoryId = ref(null); type NewAttachment = { file: File; name: string; @@ -426,7 +424,7 @@ function onSubmit() { partyId: selectedPartyId.value, amount: `${amountNum} ${selectedCurrency.value}`, category: formCategory.value, - categoryIds: selectedAdditionalCategoryIds.value, + categoryIds: selectedCategoryId.value ? [selectedCategoryId.value] : [], groupId: selectedGroupId.value ?? undefined, walletId: selectedWalletId.value, description: formDescription.value.trim(), @@ -546,8 +544,10 @@ const groupSearchQuery = ref(''); const categorySearchQuery = ref(''); const walletSearchQuery = ref(''); -function handleCategorySelect(categoryIds) { - selectedAdditionalCategoryIds.value = categoryIds; +// A transaction holds one category, so the dropdown is single-select and hands +// back the chosen option rather than a list of ids. +function handleCategorySelect(category) { + selectedCategoryId.value = category?.id ?? null; } async function loadRecentExpenses() { @@ -761,8 +761,14 @@ watch( } } + // Transactions recorded before categories were limited to one may still + // carry several; the first is kept and the rest drop away on save. if (item.categoryIds && item.categoryIds.length > 0) { - selectedAdditionalCategoryIds.value = item.categoryIds; + selectedCategoryId.value = item.categoryIds[0]; + const category = categories.value.find((c) => c.id === selectedCategoryId.value); + if (category) { + categorySearchQuery.value = category.name; + } } if (item.walletId) { diff --git a/tests/components/TransactionForm.test.ts b/tests/components/TransactionForm.test.ts index c6956dc..2318bdc 100644 --- a/tests/components/TransactionForm.test.ts +++ b/tests/components/TransactionForm.test.ts @@ -427,53 +427,67 @@ describe('TransactionForm', () => { }); describe('category selection', () => { - it('includes categoryIds in submit payload when categories selected', async () => { - let capturedSelectHandler: ((ids: number[]) => void) | null = null; + // A transaction holds one category, so the dropdown is single-select and + // hands back the chosen option rather than a list of ids. + const categoryDropdownStub = (onSetup: (props: any, emit: any) => void = () => {}) => ({ + ...stubs, + SearchableDropdown: { + template: '
', + props: ['modelValue', 'label', 'options', 'placeholder', 'error', 'disabled'], + emits: ['update:modelValue', 'select'], + setup(props: any, { emit }: any) { + onSetup(props, emit); + return {}; + } + } + }); + + it('includes the chosen category in the submit payload', async () => { + let selectCategory: (() => void) | null = null; const wrapper = mount(TransactionForm, { props: { isOutcomeSelected: true }, global: { - stubs: { - ...stubs, - SearchableDropdown: { - template: '
', - props: [ - 'modelValue', - 'label', - 'options', - 'placeholder', - 'multiple', - 'error', - 'disabled', - 'selected' - ], - emits: ['update:modelValue', 'select'], - setup(props: any, { emit }: any) { - if (props.multiple) { - capturedSelectHandler = (ids: number[]) => emit('select', ids); - } - return {}; - } + stubs: categoryDropdownStub((props, emit) => { + if (props.label === 'Category') { + selectCategory = () => emit('select', { id: 2, name: 'Gas', type: 'expense' }); } - } + }) } }); await wrapper.find('input[type="number"]').setValue('100'); + selectCategory?.(); + await wrapper.find('.submit-button').trigger('click'); - // Simulate category selection - if (capturedSelectHandler) { - capturedSelectHandler([1, 2]); - } + const payload = wrapper.emitted('submit')?.[0]?.[0] as any; + expect(payload.categoryIds).toEqual([2]); + }); + + it('replaces the category rather than accumulating them', async () => { + let emitSelect: ((option: unknown) => void) | null = null; + + const wrapper = mount(TransactionForm, { + props: { isOutcomeSelected: true }, + global: { + stubs: categoryDropdownStub((props, emit) => { + if (props.label === 'Category') { + emitSelect = (option: unknown) => emit('select', option); + } + }) + } + }); + await wrapper.find('input[type="number"]').setValue('100'); + emitSelect?.({ id: 1, name: 'Groceries', type: 'expense' }); + emitSelect?.({ id: 2, name: 'Gas', type: 'expense' }); await wrapper.find('.submit-button').trigger('click'); - expect(wrapper.emitted('submit')).toBeTruthy(); - const payload = wrapper.emitted('submit')?.[0]?.[0]; - expect(payload.categoryIds).toEqual([1, 2]); + const payload = wrapper.emitted('submit')?.[0]?.[0] as any; + expect(payload.categoryIds).toEqual([2]); }); - it('includes empty categoryIds array when no categories selected', async () => { + it('includes empty categoryIds array when no category selected', async () => { const wrapper = mount(TransactionForm, { props: { isOutcomeSelected: true }, global: { stubs } @@ -483,52 +497,24 @@ describe('TransactionForm', () => { await wrapper.find('.submit-button').trigger('click'); expect(wrapper.emitted('submit')).toBeTruthy(); - const payload = wrapper.emitted('submit')?.[0]?.[0]; + const payload = wrapper.emitted('submit')?.[0]?.[0] as any; expect(payload.categoryIds).toEqual([]); }); - it('passes selected prop to category SearchableDropdown when editing', async () => { - let receivedSelectedProp: number[] | null = null; - + it('keeps only the first category of a transaction saved before the limit', async () => { const wrapper = mount(TransactionForm, { props: { isOutcomeSelected: true, - editingItem: { - id: 1, - amount: '100 USD', - categoryIds: [1, 2] - } + editingItem: { id: 1, amount: '100 USD', categoryIds: [1, 2] } }, - global: { - stubs: { - ...stubs, - SearchableDropdown: { - template: '
', - props: [ - 'modelValue', - 'label', - 'options', - 'placeholder', - 'multiple', - 'error', - 'disabled', - 'selected' - ], - emits: ['update:modelValue', 'select'], - setup(props: any) { - if (props.multiple && props.selected) { - receivedSelectedProp = props.selected; - } - return {}; - } - } - } - } + global: { stubs } }); await wrapper.vm.$nextTick(); + await wrapper.find('.submit-button').trigger('click'); - expect(receivedSelectedProp).toEqual([1, 2]); + const payload = wrapper.emitted('submit')?.[0]?.[0] as any; + expect(payload.categoryIds).toEqual([1]); }); }); }); From 05a5115e9fecb40f89f56fcbc1f85e1d35f3570d Mon Sep 17 00:00:00 2001 From: nfebe Date: Wed, 15 Jul 2026 19:12:24 +0100 Subject: [PATCH 3/5] feat(ai): Rework the assistant's confirmation cards A card asked people to approve changes described by internal ids, opened every field of an editing form whether or not anyone wanted to edit, and stacked one full-height card per record, so a request touching a handful of transactions scrolled for pages and cost a click each. A card now names what it will change and shows the fields at rest, opening the form only when someone chooses to edit. Changes proposed together arrive as a single card carrying the count, confirmed or dismissed as one, with each entry still reviewable on its own. Confirm and dismiss are quieter, the striped edge is gone, and status reads as a chip rather than a coloured border. --- components/ai/ChatResultRenderer.vue | 19 +- components/ai/blocks/ChatActionFields.vue | 143 +++++ .../blocks/ChatProposedActionBatchBlock.vue | 367 ++++++++++++ .../ai/blocks/ChatProposedActionBlock.vue | 536 +++++++++++------- i18n/locales/de.json | 11 +- i18n/locales/en.json | 11 +- i18n/locales/es.json | 11 +- i18n/locales/fr.json | 11 +- services/api/aiApi.ts | 50 +- .../ChatProposedActionBlock.test.ts | 134 ++++- 10 files changed, 1049 insertions(+), 244 deletions(-) create mode 100644 components/ai/blocks/ChatActionFields.vue create mode 100644 components/ai/blocks/ChatProposedActionBatchBlock.vue diff --git a/components/ai/ChatResultRenderer.vue b/components/ai/ChatResultRenderer.vue index a525d58..8ac3c2f 100644 --- a/components/ai/ChatResultRenderer.vue +++ b/components/ai/ChatResultRenderer.vue @@ -1,7 +1,7 @@