@hed-hog/finance 0.0.321 → 0.0.325

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,7 +37,6 @@ import {
37
37
  import { Input } from '@/components/ui/input';
38
38
  import { InputMoney } from '@/components/ui/input-money';
39
39
  import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
40
- import { Money } from '@/components/ui/money';
41
40
  import {
42
41
  Select,
43
42
  SelectContent,
@@ -105,6 +104,13 @@ type Currency = {
105
104
  ativo: boolean;
106
105
  };
107
106
 
107
+ type BankAccountCurrency = {
108
+ id: string;
109
+ code: string;
110
+ symbol: string;
111
+ name: string;
112
+ };
113
+
108
114
  type BankAccount = {
109
115
  id: string;
110
116
  codigo: string;
@@ -115,7 +121,7 @@ type BankAccount = {
115
121
  tipo: 'corrente' | 'poupanca' | 'investimento' | 'caixa';
116
122
  logoFileId: number | null;
117
123
  currencyId: number | null;
118
- currency: Currency | null;
124
+ currency: BankAccountCurrency | null;
119
125
  saldoAtual: number;
120
126
  saldoConciliado: number;
121
127
  ativo: boolean;
@@ -147,6 +153,14 @@ type BankAccountDraftPayload = {
147
153
 
148
154
  const BANK_ACCOUNT_FORM_DRAFT_STORAGE_KEY = 'finance-bank-account-form-draft';
149
155
 
156
+ function formatCurrency(value: number, currencyCode: string, locale: string) {
157
+ const localeTag = locale.startsWith('pt') ? 'pt-BR' : 'en-US';
158
+ return new Intl.NumberFormat(localeTag, {
159
+ style: 'currency',
160
+ currency: currencyCode,
161
+ }).format(value);
162
+ }
163
+
150
164
  function BankAccountLogo({
151
165
  account,
152
166
  icon: Icon,
@@ -1049,40 +1063,52 @@ export default function ContasBancariasPage() {
1049
1063
  caixa: { label: t('types.caixa'), icon: Wallet },
1050
1064
  };
1051
1065
 
1052
- const saldoTotal = accounts
1053
- .filter((c) => c.ativo)
1054
- .reduce((acc, c) => acc + c.saldoAtual, 0);
1055
-
1056
- const saldoConciliadoTotal = accounts
1057
- .filter((c) => c.ativo)
1058
- .reduce((acc, c) => acc + c.saldoConciliado, 0);
1059
1066
  const activeAccountsCount = accounts.filter((c) => c.ativo).length;
1060
1067
  const inactiveAccountsCount = accounts.length - activeAccountsCount;
1061
- const summaryCards = [
1062
- {
1063
- key: 'balance',
1064
- title: t('cards.totalBalance'),
1065
- value: <Money value={saldoTotal} />,
1066
- description: t('cards.activeAccounts', {
1067
- count: activeAccountsCount,
1068
- }),
1069
- icon: Landmark,
1070
- layout: 'compact' as const,
1071
- },
1072
- {
1073
- key: 'reconciled',
1074
- title: t('cards.reconciledBalance'),
1075
- value: <Money value={saldoConciliadoTotal} />,
1076
- description: `${t('cards.difference')}: ${new Intl.NumberFormat(
1077
- currentLocaleCode === 'pt' ? 'pt-BR' : 'en-US',
1068
+
1069
+ // Group active account balances by currency (accounts without currency default to BRL)
1070
+ const balanceByCurrency = accounts
1071
+ .filter((c) => c.ativo)
1072
+ .reduce<
1073
+ Record<
1074
+ string,
1078
1075
  {
1079
- style: 'currency',
1080
- currency: 'BRL',
1076
+ total: number;
1077
+ reconciled: number;
1078
+ symbol: string;
1079
+ code: string;
1080
+ name: string;
1081
+ count: number;
1081
1082
  }
1082
- ).format(saldoTotal - saldoConciliadoTotal)}`,
1083
- icon: RefreshCw,
1083
+ >
1084
+ >((acc, c) => {
1085
+ const code = c.currency?.code ?? 'BRL';
1086
+ const symbol = c.currency?.symbol ?? 'R$';
1087
+ const name = c.currency?.name ?? 'Real Brasileiro';
1088
+ if (!acc[code]) {
1089
+ acc[code] = { total: 0, reconciled: 0, symbol, code, name, count: 0 };
1090
+ }
1091
+ acc[code].total += c.saldoAtual;
1092
+ acc[code].reconciled += c.saldoConciliado;
1093
+ acc[code].count += 1;
1094
+ return acc;
1095
+ }, {});
1096
+
1097
+ const currencyGroups = Object.values(balanceByCurrency);
1098
+
1099
+ const summaryCards = [
1100
+ ...currencyGroups.map((g) => ({
1101
+ key: `balance-${g.code}`,
1102
+ title: `${t('cards.totalBalance')} (${g.code})`,
1103
+ value: (
1104
+ <span className="tabular-nums">
1105
+ {formatCurrency(g.total, g.code, currentLocaleCode)}
1106
+ </span>
1107
+ ),
1108
+ description: `${t('cards.reconciledBalance')}: ${formatCurrency(g.reconciled, g.code, currentLocaleCode)}`,
1109
+ icon: Landmark,
1084
1110
  layout: 'compact' as const,
1085
- },
1111
+ })),
1086
1112
  {
1087
1113
  key: 'accounts',
1088
1114
  title: t('cards.accountsOverview'),
@@ -1175,7 +1201,10 @@ export default function ContasBancariasPage() {
1175
1201
  </AlertDialogContent>
1176
1202
  </AlertDialog>
1177
1203
 
1178
- <KpiCardsGrid items={summaryCards} columns={3} />
1204
+ <KpiCardsGrid
1205
+ items={summaryCards}
1206
+ columns={Math.min(summaryCards.length, 4) as 2 | 3 | 4}
1207
+ />
1179
1208
 
1180
1209
  <FinancePageSection variant="flat" contentClassName="p-0">
1181
1210
  {accounts.length > 0 ? (
@@ -1236,15 +1265,25 @@ export default function ContasBancariasPage() {
1236
1265
  <p className="text-sm text-muted-foreground">
1237
1266
  {t('accountCard.currentBalance')}
1238
1267
  </p>
1239
- <p className="text-2xl font-bold">
1240
- <Money value={conta.saldoAtual} />
1268
+ <p className="text-2xl font-bold tabular-nums">
1269
+ {formatCurrency(
1270
+ conta.saldoAtual,
1271
+ conta.currency?.code ?? 'BRL',
1272
+ currentLocaleCode
1273
+ )}
1241
1274
  </p>
1242
1275
  </div>
1243
1276
  <div className="flex items-center justify-between text-sm">
1244
1277
  <span className="text-muted-foreground">
1245
1278
  {t('accountCard.reconciledBalance')}
1246
1279
  </span>
1247
- <Money value={conta.saldoConciliado} />
1280
+ <span className="tabular-nums">
1281
+ {formatCurrency(
1282
+ conta.saldoConciliado,
1283
+ conta.currency?.code ?? 'BRL',
1284
+ currentLocaleCode
1285
+ )}
1286
+ </span>
1248
1287
  </div>
1249
1288
  {diferenca !== 0 ? (
1250
1289
  <div className="flex items-center justify-between text-sm">
@@ -1253,10 +1292,17 @@ export default function ContasBancariasPage() {
1253
1292
  </span>
1254
1293
  <span
1255
1294
  className={
1256
- diferenca > 0 ? 'text-green-600' : 'text-red-600'
1295
+ diferenca > 0
1296
+ ? 'tabular-nums text-green-600'
1297
+ : 'tabular-nums text-red-600'
1257
1298
  }
1258
1299
  >
1259
- <Money value={diferenca} showSign />
1300
+ {diferenca > 0 ? '+' : '-'}
1301
+ {formatCurrency(
1302
+ Math.abs(diferenca),
1303
+ conta.currency?.code ?? 'BRL',
1304
+ currentLocaleCode
1305
+ )}
1260
1306
  </span>
1261
1307
  </div>
1262
1308
  ) : null}
@@ -30,6 +30,7 @@ interface FinanceData {
30
30
  saldoAtual?: number | null;
31
31
  saldoConciliado?: number | null;
32
32
  ativo?: boolean | null;
33
+ currency?: { code: string; symbol: string } | null;
33
34
  }>;
34
35
  }
35
36
 
@@ -68,13 +69,22 @@ export default function BankReconciliationStatus({
68
69
  difference: Math.abs(
69
70
  Number(account.saldoAtual || 0) - Number(account.saldoConciliado || 0)
70
71
  ),
72
+ currencyCode: account.currency?.code ?? 'BRL',
73
+ currencySymbol: account.currency?.symbol ?? 'R$',
71
74
  }))
72
75
  .sort((a, b) => b.difference - a.difference);
73
76
 
74
- const totalDifference = accountDiffs.reduce(
75
- (acc, account) => acc + account.difference,
76
- 0
77
- );
77
+ // Group differences by currency — never mix different currencies in a single total
78
+ const differencesByCurrency = accountDiffs.reduce<
79
+ Record<string, { total: number; symbol: string }>
80
+ >((acc, account) => {
81
+ const code = account.currencyCode;
82
+ if (!acc[code]) {
83
+ acc[code] = { total: 0, symbol: account.currencySymbol };
84
+ }
85
+ acc[code].total += account.difference;
86
+ return acc;
87
+ }, {});
78
88
 
79
89
  return (
80
90
  <WidgetWrapper
@@ -109,9 +119,20 @@ export default function BankReconciliationStatus({
109
119
  <p className="text-[11px] font-medium uppercase tracking-[0.18em] text-muted-foreground">
110
120
  {t('bankReconciliation.difference')}
111
121
  </p>
112
- <p className="text-lg font-semibold text-foreground">
113
- <Money value={totalDifference} />
114
- </p>
122
+ {Object.entries(differencesByCurrency).length > 0 ? (
123
+ Object.entries(differencesByCurrency).map(([code, entry]) => (
124
+ <p key={code} className="text-lg font-semibold text-foreground tabular-nums">
125
+ {new Intl.NumberFormat('pt-BR', {
126
+ style: 'currency',
127
+ currency: code,
128
+ }).format(entry.total)}
129
+ </p>
130
+ ))
131
+ ) : (
132
+ <p className="text-lg font-semibold text-foreground tabular-nums">
133
+ <Money value={0} />
134
+ </p>
135
+ )}
115
136
  </div>
116
137
 
117
138
  <div className="space-y-2">
@@ -123,8 +144,11 @@ export default function BankReconciliationStatus({
123
144
  <span className="truncate pr-3 text-sm text-foreground">
124
145
  {account.name}
125
146
  </span>
126
- <span className="text-sm font-medium text-muted-foreground">
127
- <Money value={account.difference} />
147
+ <span className="text-sm font-medium text-muted-foreground tabular-nums">
148
+ {new Intl.NumberFormat('pt-BR', {
149
+ style: 'currency',
150
+ currency: account.currencyCode,
151
+ }).format(account.difference)}
128
152
  </span>
129
153
  </div>
130
154
  ))}
@@ -14,6 +14,10 @@ interface CashBalanceKpiProps {
14
14
  interface FinanceData {
15
15
  kpis?: {
16
16
  saldoCaixa: number;
17
+ saldoCaixaPorMoeda?: Record<
18
+ string,
19
+ { total: number; reconciled: number; symbol: string; name: string }
20
+ >;
17
21
  };
18
22
  }
19
23
 
@@ -29,11 +33,11 @@ export default function CashBalanceKpi({
29
33
  queryKey: 'finance-kpi-cash-balance',
30
34
  });
31
35
 
32
- const value = data?.kpis?.saldoCaixa ?? 0;
33
- const formatted = new Intl.NumberFormat('pt-BR', {
34
- style: 'currency',
35
- currency: 'BRL',
36
- }).format(value);
36
+ const saldoCaixaPorMoeda = data?.kpis?.saldoCaixaPorMoeda ?? {};
37
+ const currencyEntries = Object.entries(saldoCaixaPorMoeda);
38
+ // Fallback to the legacy single-value when the new field is absent
39
+ const legacyValue = data?.kpis?.saldoCaixa ?? 0;
40
+ const hasCurrencyBreakdown = currencyEntries.length > 0;
37
41
 
38
42
  return (
39
43
  <WidgetWrapper
@@ -48,13 +52,30 @@ export default function CashBalanceKpi({
48
52
  <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-emerald-100/80">
49
53
  <Wallet className="h-4.5 w-4.5 text-emerald-600" />
50
54
  </div>
51
- <div className="flex min-w-0 flex-1 flex-col justify-center">
55
+ <div className="flex min-w-0 flex-1 flex-col justify-center gap-0.5">
52
56
  <span className="text-[9px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
53
57
  {t('kpis.cashBalance.title')}
54
58
  </span>
55
- <span className="truncate text-base font-bold leading-none tracking-tight text-foreground sm:text-lg">
56
- {formatted}
57
- </span>
59
+ {hasCurrencyBreakdown ? (
60
+ currencyEntries.map(([code, entry]) => (
61
+ <span
62
+ key={code}
63
+ className="truncate text-base font-bold leading-none tracking-tight text-foreground sm:text-lg"
64
+ >
65
+ {new Intl.NumberFormat('pt-BR', {
66
+ style: 'currency',
67
+ currency: code,
68
+ }).format(entry.total)}
69
+ </span>
70
+ ))
71
+ ) : (
72
+ <span className="truncate text-base font-bold leading-none tracking-tight text-foreground sm:text-lg">
73
+ {new Intl.NumberFormat('pt-BR', {
74
+ style: 'currency',
75
+ currency: 'BRL',
76
+ }).format(legacyValue)}
77
+ </span>
78
+ )}
58
79
  <span className="truncate text-[9px] text-muted-foreground">
59
80
  {t('kpis.cashBalance.description')}
60
81
  </span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/finance",
3
- "version": "0.0.321",
3
+ "version": "0.0.325",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -11,12 +11,12 @@
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api": "0.0.8",
13
13
  "@hed-hog/api-locale": "0.0.14",
14
- "@hed-hog/contact": "0.0.321",
15
- "@hed-hog/api-pagination": "0.0.7",
16
14
  "@hed-hog/api-prisma": "0.0.6",
15
+ "@hed-hog/contact": "0.0.325",
16
+ "@hed-hog/tag": "0.0.325",
17
17
  "@hed-hog/api-types": "0.0.1",
18
- "@hed-hog/tag": "0.0.321",
19
- "@hed-hog/core": "0.0.321"
18
+ "@hed-hog/api-pagination": "0.0.7",
19
+ "@hed-hog/core": "0.0.325"
20
20
  },
21
21
  "exports": {
22
22
  ".": {
@@ -1,11 +1,11 @@
1
1
  import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import {
3
- IsDateString,
4
- IsInt,
5
- IsNumber,
6
- IsOptional,
7
- IsString,
8
- Min,
3
+ IsDateString,
4
+ IsInt,
5
+ IsNumber,
6
+ IsOptional,
7
+ IsString,
8
+ Min,
9
9
  } from 'class-validator';
10
10
 
11
11
  export class CreateBankAccountDto {
@@ -47,6 +47,12 @@ export class CreateBankAccountDto {
47
47
  })
48
48
  logo_file_id?: number | null;
49
49
 
50
+ @IsOptional()
51
+ @IsInt({
52
+ message: (args) => getLocaleText('validation.idMustBeInteger', args.value),
53
+ })
54
+ currency_id?: number | null;
55
+
50
56
  @IsOptional()
51
57
  @IsNumber(
52
58
  {},
@@ -42,6 +42,12 @@ export class UpdateBankAccountDto {
42
42
  })
43
43
  logo_file_id?: number | null;
44
44
 
45
+ @IsOptional()
46
+ @IsInt({
47
+ message: (args) => getLocaleText('validation.idMustBeInteger', args.value),
48
+ })
49
+ currency_id?: number | null;
50
+
45
51
  @IsOptional()
46
52
  @IsString({
47
53
  message: (args) =>
@@ -2286,9 +2286,36 @@ export class FinanceService {
2286
2286
  );
2287
2287
  const receivableInstallments = this.extractOpenInstallments(receivables);
2288
2288
 
2289
- const saldoCaixa = (bankAccounts || [])
2290
- .filter((account) => account?.ativo !== false)
2291
- .reduce((acc, account) => acc + Number(account?.saldoAtual || 0), 0);
2289
+ const activeAccounts = (bankAccounts || []).filter(
2290
+ (account) => account?.ativo !== false,
2291
+ );
2292
+
2293
+ const saldoCaixa = activeAccounts.reduce(
2294
+ (acc, account) => acc + Number(account?.saldoAtual || 0),
2295
+ 0,
2296
+ );
2297
+
2298
+ const saldoCaixaPorMoeda: Record<
2299
+ string,
2300
+ { total: number; reconciled: number; symbol: string; name: string }
2301
+ > = {};
2302
+ for (const account of activeAccounts) {
2303
+ const code = account?.currency?.code || 'BRL';
2304
+ const symbol = account?.currency?.symbol || 'R$';
2305
+ const name = account?.currency?.name || 'Real Brasileiro';
2306
+ if (!saldoCaixaPorMoeda[code]) {
2307
+ saldoCaixaPorMoeda[code] = { total: 0, reconciled: 0, symbol, name };
2308
+ }
2309
+ saldoCaixaPorMoeda[code].total = Number(
2310
+ (saldoCaixaPorMoeda[code].total + Number(account?.saldoAtual || 0)).toFixed(2),
2311
+ );
2312
+ saldoCaixaPorMoeda[code].reconciled = Number(
2313
+ (
2314
+ saldoCaixaPorMoeda[code].reconciled +
2315
+ Number(account?.saldoConciliado || 0)
2316
+ ).toFixed(2),
2317
+ );
2318
+ }
2292
2319
 
2293
2320
  const aPagar7dias = this.sumInstallmentsDueBetween(
2294
2321
  payableInstallments,
@@ -2318,6 +2345,7 @@ export class FinanceService {
2318
2345
 
2319
2346
  return {
2320
2347
  saldoCaixa: Number(saldoCaixa.toFixed(2)),
2348
+ saldoCaixaPorMoeda,
2321
2349
  aPagar30dias: Number(aPagar30dias.toFixed(2)),
2322
2350
  aPagar7dias: Number(aPagar7dias.toFixed(2)),
2323
2351
  aReceber30dias: Number(aReceber30dias.toFixed(2)),
@@ -2794,6 +2822,14 @@ export class FinanceService {
2794
2822
  description: true,
2795
2823
  },
2796
2824
  },
2825
+ currency: {
2826
+ select: {
2827
+ id: true,
2828
+ code: true,
2829
+ symbol: true,
2830
+ name: true,
2831
+ },
2832
+ },
2797
2833
  },
2798
2834
  orderBy: [{ code: 'asc' }, { name: 'asc' }],
2799
2835
  },
@@ -4430,6 +4466,7 @@ export class FinanceService {
4430
4466
  account_number: data.account || null,
4431
4467
  account_type: accountType,
4432
4468
  logo_file_id: data.logo_file_id ?? null,
4469
+ currency_id: data.currency_id ?? null,
4433
4470
  status: 'active',
4434
4471
  },
4435
4472
  });
@@ -4478,6 +4515,14 @@ export class FinanceService {
4478
4515
  description: true,
4479
4516
  },
4480
4517
  },
4518
+ currency: {
4519
+ select: {
4520
+ id: true,
4521
+ code: true,
4522
+ symbol: true,
4523
+ name: true,
4524
+ },
4525
+ },
4481
4526
  },
4482
4527
  });
4483
4528
 
@@ -4587,6 +4632,8 @@ export class FinanceService {
4587
4632
  account_type: data.type ? this.mapAccountTypeFromPt(data.type) : undefined,
4588
4633
  logo_file_id:
4589
4634
  data.logo_file_id === undefined ? undefined : data.logo_file_id,
4635
+ currency_id:
4636
+ data.currency_id === undefined ? undefined : data.currency_id,
4590
4637
  status: data.status,
4591
4638
  },
4592
4639
  include: {
@@ -4598,6 +4645,14 @@ export class FinanceService {
4598
4645
  description: true,
4599
4646
  },
4600
4647
  },
4648
+ currency: {
4649
+ select: {
4650
+ id: true,
4651
+ code: true,
4652
+ symbol: true,
4653
+ name: true,
4654
+ },
4655
+ },
4601
4656
  },
4602
4657
  });
4603
4658
 
@@ -4758,7 +4813,7 @@ export class FinanceService {
4758
4813
  }
4759
4814
 
4760
4815
  async createCurrency(data: CreateCurrencyDto) {
4761
- const existing = await this.prisma.currency.findUnique({
4816
+ const existing = await this.prisma.currency.findFirst({
4762
4817
  where: { code: data.code.toUpperCase() },
4763
4818
  select: { id: true },
4764
4819
  });
@@ -6465,6 +6520,14 @@ export class FinanceService {
6465
6520
  description: true,
6466
6521
  },
6467
6522
  },
6523
+ currency: {
6524
+ select: {
6525
+ id: true,
6526
+ code: true,
6527
+ symbol: true,
6528
+ name: true,
6529
+ },
6530
+ },
6468
6531
  },
6469
6532
  orderBy: [{ code: 'asc' }, { name: 'asc' }],
6470
6533
  });
@@ -7016,6 +7079,15 @@ export class FinanceService {
7016
7079
  conta: bankAccount.account_number || '-',
7017
7080
  tipo: this.mapAccountTypeToPt(bankAccount.account_type),
7018
7081
  logoFileId: bankAccount.logo_file_id ?? null,
7082
+ currencyId: bankAccount.currency_id ?? null,
7083
+ currency: bankAccount.currency
7084
+ ? {
7085
+ id: String(bankAccount.currency.id),
7086
+ code: bankAccount.currency.code,
7087
+ symbol: bankAccount.currency.symbol,
7088
+ name: bankAccount.currency.name,
7089
+ }
7090
+ : null,
7019
7091
  saldoAtual: this.fromCents(currentCents),
7020
7092
  saldoConciliado: this.fromCents(reconciledCents),
7021
7093
  ativo: bankAccount.status === 'active',