el-contador 1.2.14 → 1.2.16

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.
Files changed (36) hide show
  1. package/docker-compose.yml +7 -0
  2. package/frontend/src/components/ui/checkbox.tsx +1 -1
  3. package/frontend/src/components/ui/select.tsx +3 -3
  4. package/frontend/src/components/ui/switch.tsx +2 -2
  5. package/frontend/src/hooks/useContactLinks.ts +74 -0
  6. package/frontend/src/hooks/useContacts.ts +14 -0
  7. package/frontend/src/hooks/useExpenses.ts +3 -1
  8. package/frontend/src/hooks/useSettings.ts +25 -0
  9. package/frontend/src/index.css +5 -1
  10. package/frontend/src/pages/AccountLedger.tsx +3 -5
  11. package/frontend/src/pages/Contacts.tsx +365 -17
  12. package/frontend/src/pages/Dashboard.tsx +86 -23
  13. package/frontend/src/pages/Expenses.tsx +9 -5
  14. package/frontend/src/pages/Reconciliation.tsx +266 -18
  15. package/frontend/src/pages/Sales.tsx +11 -3
  16. package/frontend/src/pages/Settings.tsx +243 -68
  17. package/frontend/tailwind.config.js +20 -19
  18. package/package.json +1 -1
  19. package/server/db/schema.sql +50 -0
  20. package/server/index.js +10 -1
  21. package/server/package-lock.json +2 -2
  22. package/server/package.json +1 -1
  23. package/server/routes/accounts.js +3 -11
  24. package/server/routes/contact-links.js +82 -0
  25. package/server/routes/customers.js +85 -0
  26. package/server/routes/expenses.js +7 -16
  27. package/server/routes/integrations.js +140 -6
  28. package/server/routes/reconciliation.js +222 -2
  29. package/server/routes/sales.js +14 -1
  30. package/server/routes/suppliers.js +0 -12
  31. package/server/routes/webhooks-payments.js +148 -0
  32. package/server/scripts/test-offset-e2e.js +96 -0
  33. package/server/services/email.js +9 -2
  34. package/server/services/invoice-pdf.js +122 -44
  35. package/server/services/journal-posting.js +66 -0
  36. package/server/services/payment-sales-import.js +260 -0
@@ -41,6 +41,13 @@ services:
41
41
  INIT_ADMIN_PASSWORD: ${INIT_ADMIN_PASSWORD}
42
42
  GEMINI_API_KEY: ${GEMINI_API_KEY:-}
43
43
  GEMINI_MODEL: ${GEMINI_MODEL:-gemini-3.1-flash-lite-preview}
44
+ SMTP_HOST: ${SMTP_HOST:-}
45
+ SMTP_TLS_SERVERNAME: ${SMTP_TLS_SERVERNAME:-}
46
+ SMTP_PORT: ${SMTP_PORT:-587}
47
+ SMTP_SECURE: ${SMTP_SECURE:-false}
48
+ SMTP_USER: ${SMTP_USER:-}
49
+ SMTP_PASS: ${SMTP_PASS:-}
50
+ SMTP_FROM: ${SMTP_FROM:-}
44
51
  PORT: 3080
45
52
  ports:
46
53
  - "${ADMIN_PORT:-3080}:3080"
@@ -10,7 +10,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
10
10
  <CheckboxPrimitive.Root
11
11
  data-slot="checkbox"
12
12
  className={cn(
13
- "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
13
+ "peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-[checked]:border-primary data-[checked]:bg-primary data-[checked]:text-primary-foreground dark:data-[checked]:bg-primary",
14
14
  className
15
15
  )}
16
16
  {...props}
@@ -39,7 +39,7 @@ function SelectTrigger({
39
39
  data-slot="select-trigger"
40
40
  data-size={size}
41
41
  className={cn(
42
- "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
42
+ "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-background py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
43
43
  className
44
44
  )}
45
45
  {...props}
@@ -81,11 +81,11 @@ function SelectContent({
81
81
  <SelectPrimitive.Popup
82
82
  data-slot="select-content"
83
83
  data-align-trigger={alignItemWithTrigger}
84
- className={cn("relative isolate z-[200] max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover pb-[15px] text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
84
+ className={cn("relative isolate z-[200] max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover pb-[15px] text-popover-foreground shadow-md duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:zoom-in-95 data-closed:animate-out data-closed:zoom-out-95", className )}
85
85
  {...props}
86
86
  >
87
87
  <SelectScrollUpButton />
88
- <SelectPrimitive.List>{children}</SelectPrimitive.List>
88
+ <SelectPrimitive.List className="bg-popover">{children}</SelectPrimitive.List>
89
89
  <SelectScrollDownButton />
90
90
  </SelectPrimitive.Popup>
91
91
  </SelectPrimitive.Positioner>
@@ -14,14 +14,14 @@ function Switch({
14
14
  data-slot="switch"
15
15
  data-size={size}
16
16
  className={cn(
17
- "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
17
+ "peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-[checked]:bg-primary data-[unchecked]:bg-input dark:data-[unchecked]:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
18
18
  className
19
19
  )}
20
20
  {...props}
21
21
  >
22
22
  <SwitchPrimitive.Thumb
23
23
  data-slot="switch-thumb"
24
- className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
24
+ className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-[checked]:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-[checked]:translate-x-[calc(100%-2px)] dark:data-[checked]:bg-primary-foreground group-data-[size=default]/switch:data-[unchecked]:translate-x-0 group-data-[size=sm]/switch:data-[unchecked]:translate-x-0 dark:data-[unchecked]:bg-foreground"
25
25
  />
26
26
  </SwitchPrimitive.Root>
27
27
  )
@@ -0,0 +1,74 @@
1
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
2
+ import { api } from '../lib/api';
3
+
4
+ export type ContactLink = {
5
+ id: string;
6
+ supplierId: string;
7
+ supplierName: string;
8
+ customerId: string;
9
+ customerName: string;
10
+ notes: string;
11
+ createdAt: string;
12
+ };
13
+
14
+ export type InvoiceSettlement = {
15
+ id: string;
16
+ date: string;
17
+ description: string;
18
+ adjustmentAmount: number;
19
+ contactLinkId: string | null;
20
+ supplierId: string | null;
21
+ supplierName: string | null;
22
+ customerId: string | null;
23
+ customerName: string | null;
24
+ createdAt: string;
25
+ salesTotal: number;
26
+ expenseTotal: number;
27
+ sales: Array<{ id: string; invoiceNo: string; customer: string; total: number }>;
28
+ expenses: Array<{ id: string; vendor: string; invoiceNumber: string | null; total: number }>;
29
+ };
30
+
31
+ export function useContactLinks() {
32
+ return useQuery({
33
+ queryKey: ['contact-links'],
34
+ queryFn: async () => {
35
+ const { data } = await api.get<ContactLink[]>('/contact-links');
36
+ return data;
37
+ },
38
+ });
39
+ }
40
+
41
+ export function useCreateContactLink() {
42
+ const queryClient = useQueryClient();
43
+ return useMutation({
44
+ mutationFn: async (body: { supplierId: string; customerId: string; notes?: string }) => {
45
+ const { data } = await api.post<ContactLink>('/contact-links', body);
46
+ return data;
47
+ },
48
+ onSuccess: () => {
49
+ queryClient.invalidateQueries({ queryKey: ['contact-links'] });
50
+ },
51
+ });
52
+ }
53
+
54
+ export function useDeleteContactLink() {
55
+ const queryClient = useQueryClient();
56
+ return useMutation({
57
+ mutationFn: async (id: string) => {
58
+ await api.delete(`/contact-links/${id}`);
59
+ },
60
+ onSuccess: () => {
61
+ queryClient.invalidateQueries({ queryKey: ['contact-links'] });
62
+ },
63
+ });
64
+ }
65
+
66
+ export function useInvoiceSettlements() {
67
+ return useQuery({
68
+ queryKey: ['reconciliation', 'settlements'],
69
+ queryFn: async () => {
70
+ const { data } = await api.get<{ settlements: InvoiceSettlement[] }>('/reconciliation/settlements');
71
+ return data.settlements;
72
+ },
73
+ });
74
+ }
@@ -75,6 +75,20 @@ export function useDeleteCustomer() {
75
75
  });
76
76
  }
77
77
 
78
+ export function useMergeCustomers() {
79
+ const queryClient = useQueryClient();
80
+ return useMutation({
81
+ mutationFn: async (body: { keepCustomerId: string; mergeCustomerIds: string[] }) => {
82
+ const { data } = await api.post<{ ok: boolean; mergedCount: number }>('/customers/merge', body);
83
+ return data;
84
+ },
85
+ onSuccess: () => {
86
+ queryClient.invalidateQueries({ queryKey: ['customers'] });
87
+ queryClient.invalidateQueries({ queryKey: ['sales'] });
88
+ },
89
+ });
90
+ }
91
+
78
92
  // Suppliers
79
93
  export function useSuppliers() {
80
94
  return useQuery({
@@ -22,6 +22,8 @@ export type Expense = {
22
22
  reconciledAt: string | null;
23
23
  supplierId: string | null;
24
24
  bankTransactionId: string | null;
25
+ settlementId?: string | null;
26
+ paymentSource?: 'bank' | 'settlement' | null;
25
27
  createdAt: string;
26
28
  createdBy?: string | null;
27
29
  creatorEmail?: string | null;
@@ -50,7 +52,7 @@ export function useExpenseCategories() {
50
52
  });
51
53
  }
52
54
 
53
- /** Accounts for expenses: not asset/revenue (100-299) and Allow expenses enabled on the account */
55
+ /** Accounts for expenses: Allow expenses enabled on the account */
54
56
  export function useExpenseAccounts() {
55
57
  return useQuery({
56
58
  queryKey: ['accounts', 'expense'],
@@ -60,6 +60,31 @@ export function useSaveIntegrationsSettings() {
60
60
  });
61
61
  }
62
62
 
63
+ export type IntegrationTestResult = { ok: boolean; error?: string; detail?: string };
64
+
65
+ export function useTestIntegrationConnection() {
66
+ return useMutation({
67
+ mutationFn: async (payload: { provider: 'stripe' | 'paddle'; stripeSecretKey?: string; paddleApiKey?: string }) => {
68
+ const { data } = await api.post<{ testResults: { stripe?: IntegrationTestResult; paddle?: IntegrationTestResult } }>(
69
+ '/integrations/test',
70
+ payload
71
+ );
72
+ return data;
73
+ },
74
+ });
75
+ }
76
+
77
+ export type PaymentSyncResult = { imported: number; skipped: number; errors: { id?: string; error: string }[] };
78
+
79
+ export function useSyncPaymentIntegrations() {
80
+ return useMutation({
81
+ mutationFn: async (payload: { provider: 'stripe' | 'paddle'; limit?: number }) => {
82
+ const { data } = await api.post<PaymentSyncResult>('/integrations/sync', payload);
83
+ return data;
84
+ },
85
+ });
86
+ }
87
+
63
88
  // Users
64
89
  export function useUsers() {
65
90
  return useQuery({
@@ -22,6 +22,7 @@
22
22
  --accent: oklch(0.97 0 0);
23
23
  --accent-foreground: oklch(0.205 0 0);
24
24
  --destructive: oklch(0.58 0.22 27);
25
+ --destructive-foreground: oklch(0.985 0 0);
25
26
  --border: oklch(0.922 0 0);
26
27
  --input: oklch(0.922 0 0);
27
28
  --ring: oklch(0.708 0 0);
@@ -68,6 +69,7 @@
68
69
  --accent: oklch(0.371 0 0);
69
70
  --accent-foreground: oklch(0.985 0 0);
70
71
  --destructive: oklch(0.704 0.191 22.216);
72
+ --destructive-foreground: oklch(0.985 0 0);
71
73
  --border: oklch(1 0 0 / 10%);
72
74
  --input: oklch(1 0 0 / 15%);
73
75
  --ring: oklch(0.556 0 0);
@@ -99,7 +101,9 @@
99
101
  --font-sans: 'Geist Variable', sans-serif
100
102
  }
101
103
  * {
102
- @apply border-border outline-ring/50;
104
+ @apply border-border;
105
+ /* outline-ring/50 is not generated for theme colors defined as full var(--token) */
106
+ outline-color: color-mix(in oklch, var(--ring) 50%, transparent);
103
107
  }
104
108
  body {
105
109
  @apply bg-background text-foreground;
@@ -216,7 +216,7 @@ export default function AccountLedger() {
216
216
  <div className="flex flex-wrap items-center gap-2">
217
217
  <Select value={year} onValueChange={(v) => setYear(v ?? 'all')}>
218
218
  <SelectTrigger className="w-24">
219
- <SelectValue placeholder="Year">{year === 'all' ? 'All (historic)' : year}</SelectValue>
219
+ <SelectValue placeholder="Year" />
220
220
  </SelectTrigger>
221
221
  <SelectContent>
222
222
  <SelectItem value="all">All (historic)</SelectItem>
@@ -240,7 +240,7 @@ export default function AccountLedger() {
240
240
  <>
241
241
  <Select value={quarter} onValueChange={(v) => setQuarter(v ?? 'all')}>
242
242
  <SelectTrigger className="w-28">
243
- <SelectValue placeholder="Quarter">{quarter === 'all' ? 'All' : `Q${quarter}`}</SelectValue>
243
+ <SelectValue placeholder="Quarter" />
244
244
  </SelectTrigger>
245
245
  <SelectContent>
246
246
  <SelectItem value="all">All</SelectItem>
@@ -251,9 +251,7 @@ export default function AccountLedger() {
251
251
  </Select>
252
252
  <Select value={month} onValueChange={(v) => setMonth(v ?? 'all')}>
253
253
  <SelectTrigger className="w-28">
254
- <SelectValue placeholder="Month">
255
- {month === 'all' ? 'All' : new Date(2000, Number(month) - 1, 1).toLocaleString('default', { month: 'short' })}
256
- </SelectValue>
254
+ <SelectValue placeholder="Month" />
257
255
  </SelectTrigger>
258
256
  <SelectContent>
259
257
  <SelectItem value="all">All</SelectItem>