Compare commits

...

10 Commits

Author SHA1 Message Date
Matt 3ba8542e4f feat: Add currency selection and conversion support in PDF generation, enhance expense processing with target currency handling 2025-07-10 14:02:14 -04:00
Matt 2928d9a7ed feat: Enhance authentication middleware and token refresh logic with improved caching, retry mechanisms, and error handling 2025-07-10 13:31:58 -04:00
Matt 6e99f4f783 feat: Enhance receipt processing in PDF generation with overall receipt numbering and improved path extraction for MinIO 2025-07-10 12:57:43 -04:00
Matt a00b3918be feat: Enhance error handling and logging in expense and interest duplicate detection, add retry logic for document deletion, and improve PDF generation with detailed receipt processing 2025-07-10 09:59:17 -04:00
Matt 06500a614d updates 2025-07-09 22:47:52 -04:00
Matt 9d49245efa refactor: replace Puppeteer with PDFKit for PDF generation
- Updated package.json to remove Puppeteer and add PDFKit and its types.
- Refactored generate-pdf.ts to utilize PDFKit for generating PDFs instead of Puppeteer.
- Implemented functions to add headers, summaries, expense tables, and receipt images using PDFKit.
- Removed HTML content generation and related functions, streamlining the PDF generation process.
- Added error handling for receipt image fetching and improved logging.
2025-07-09 22:38:46 -04:00
Matt 893927d4b1 Refactor expense form and add PDF generation functionality
- Update expense form fields (merchant->establishmentName, amount->price)
- Add PDF generation with Puppeteer integration
- Create PDFOptionsModal component for export options
- Update expense form validation and UI layout
- Add server API endpoint for PDF generation
2025-07-09 22:23:50 -04:00
Matt b6d71faf5f feat: Improve role verification logic in InterestDuplicateNotificationBanner for duplicate checks 2025-07-09 22:08:08 -04:00
Matt 3f90db0392 feat: Update distribution method in EOI document generation to 'NONE' 2025-07-09 21:59:06 -04:00
Matt a83895bef3 feat: Update Documenso configuration in .env.example and refactor generate-eoi-document to use environment variables for recipient IDs and template ID 2025-07-09 21:51:42 -04:00
18 changed files with 1621 additions and 414 deletions

View File

@ -17,6 +17,10 @@ NUXT_EMAIL_LOGO_URL=https://portnimara.com/Port_Nimara_Logo_2_Colour_New_Transpa
# Documenso Configuration
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
NUXT_DOCUMENSO_TEMPLATE_ID=1
NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID=1
NUXT_DOCUMENSO_DAVID_RECIPIENT_ID=2
NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID=3
# Webhook Configuration for Embedded Signing
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=

View File

@ -198,7 +198,7 @@
{{ getSignatureStatusText('cc') }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</v-list-item-subtitle>
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Approval</v-list-item-subtitle>
<template v-slot:append>
<div class="d-flex gap-1">
<v-btn

View File

@ -7,7 +7,7 @@
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-receipt-text</v-icon>
<v-icon class="mr-2">mdi-plus</v-icon>
<span>Add New Expense</span>
<v-spacer />
<v-btn
@ -19,44 +19,35 @@
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="saveExpense">
<v-form ref="form" @submit.prevent="handleSubmit">
<v-row>
<!-- Merchant/Description -->
<!-- Establishment Name -->
<v-col cols="12">
<v-text-field
v-model="expense.merchant"
label="Merchant/Description"
v-model="expense.establishmentName"
label="Establishment Name"
variant="outlined"
:rules="[rules.required]"
required
placeholder="e.g., Shell, American Airlines, etc."
/>
</v-col>
<!-- Amount and Currency -->
<v-col cols="8">
<!-- Price -->
<v-col cols="12" sm="6">
<v-text-field
v-model="expense.amount"
label="Amount"
type="number"
step="0.01"
v-model="expense.price"
label="Price"
variant="outlined"
:rules="[rules.required, rules.positive]"
required
/>
</v-col>
<v-col cols="4">
<v-select
v-model="expense.currency"
:items="currencies"
label="Currency"
variant="outlined"
:rules="[rules.required]"
:rules="[rules.required, rules.price]"
required
placeholder="e.g., 59.95"
prepend-inner-icon="mdi-currency-eur"
/>
</v-col>
<!-- Category -->
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-select
v-model="expense.category"
:items="categories"
@ -68,60 +59,49 @@
</v-col>
<!-- Payer -->
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-text-field
v-model="expense.payer"
label="Payer"
variant="outlined"
:rules="[rules.required]"
required
placeholder="e.g., John, Mary, etc."
/>
</v-col>
<!-- Payment Method -->
<v-col cols="12" sm="6">
<v-select
v-model="expense.paymentMethod"
:items="paymentMethods"
label="Payment Method"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Date -->
<v-col cols="12" md="6">
<v-col cols="12">
<v-text-field
v-model="expense.date"
label="Date"
type="date"
label="Date"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Time -->
<v-col cols="12" md="6">
<v-text-field
v-model="expense.time"
label="Time"
type="time"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Notes -->
<!-- Contents/Description -->
<v-col cols="12">
<v-textarea
v-model="expense.notes"
label="Notes (Optional)"
v-model="expense.contents"
label="Description (optional)"
variant="outlined"
rows="3"
auto-grow
/>
</v-col>
<!-- Receipt Upload -->
<v-col cols="12">
<v-file-input
v-model="expense.receipt"
label="Receipt Image (Optional)"
accept="image/*"
variant="outlined"
prepend-icon="mdi-camera"
show-size
placeholder="Additional details about the expense..."
/>
</v-col>
</v-row>
@ -133,17 +113,19 @@
<v-btn
@click="closeModal"
variant="text"
:disabled="saving"
:disabled="creating"
>
Cancel
</v-btn>
<v-btn
@click="saveExpense"
@click="handleSubmit"
:disabled="creating"
color="primary"
:loading="saving"
:disabled="!isValid"
:loading="creating"
>
Add Expense
<v-icon v-if="!creating" class="mr-1">mdi-plus</v-icon>
Create Expense
</v-btn>
</v-card-actions>
</v-card>
@ -151,82 +133,81 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import type { Expense } from '@/utils/types';
import { ref, computed, watch } from 'vue';
// Props
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'created', expense: Expense): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'created': [expense: any];
}>();
// Computed dialog model
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Reactive state
const form = ref();
const saving = ref(false);
const creating = ref(false);
// Form data
const expense = ref({
merchant: '',
amount: '',
currency: 'EUR',
establishmentName: '',
price: '',
category: '',
payer: '',
paymentMethod: '',
date: '',
time: '',
notes: '',
receipt: null as File[] | null
contents: ''
});
// Form options
const currencies = ['EUR', 'USD', 'GBP', 'AUD', 'CAD', 'CHF', 'SEK', 'NOK', 'DKK'];
const categories = ['Food/Drinks', 'Shop', 'Online', 'Transportation', 'Accommodation', 'Entertainment', 'Other'];
const categories = [
'Food/Drinks',
'Shop',
'Online',
'Other'
];
const paymentMethods = [
'Card',
'Cash',
'N/A'
];
// Validation rules
const rules = {
required: (value: any) => !!value || 'This field is required',
positive: (value: any) => {
required: (value: string) => !!value || 'This field is required',
price: (value: string) => {
if (!value) return 'Price is required';
const num = parseFloat(value);
return (!isNaN(num) && num > 0) || 'Amount must be positive';
if (isNaN(num) || num <= 0) return 'Please enter a valid price';
return true;
}
};
// Computed properties
const isValid = computed(() => {
return !!(
expense.value.merchant &&
expense.value.amount &&
expense.value.currency &&
expense.value.category &&
expense.value.payer &&
expense.value.date &&
expense.value.time &&
parseFloat(expense.value.amount) > 0
);
});
// Methods
const closeModal = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
const now = new Date();
expense.value = {
merchant: '',
amount: '',
currency: 'EUR',
establishmentName: '',
price: '',
category: '',
payer: '',
date: now.toISOString().slice(0, 10),
time: now.toTimeString().slice(0, 5),
notes: '',
receipt: null
paymentMethod: '',
date: '',
contents: ''
};
if (form.value) {
@ -234,89 +215,56 @@ const resetForm = () => {
}
};
const closeModal = () => {
if (!saving.value) {
dialog.value = false;
}
};
const saveExpense = async () => {
const handleSubmit = async () => {
if (!form.value) return;
const { valid } = await form.value.validate();
if (!valid) return;
saving.value = true;
creating.value = true;
try {
// Combine date and time for the API
const dateTime = `${expense.value.date}T${expense.value.time}:00`;
// Prepare the expense data
const expenseData = {
"Establishment Name": expense.value.merchant,
Price: `${expense.value.currency}${expense.value.amount}`,
Category: expense.value.category,
Payer: expense.value.payer,
Time: dateTime,
Contents: expense.value.notes || null,
"Payment Method": "Card", // Default to Card for now
Paid: false,
currency: expense.value.currency
};
console.log('[ExpenseCreateModal] Creating expense:', expenseData);
// Call API to create expense
const response = await $fetch<Expense>('/api/create-expense', {
// Create expense via API
const response = await $fetch<{
success: boolean;
data?: any;
message?: string;
}>('/api/create-expense', {
method: 'POST',
body: expenseData
body: {
'Establishment Name': expense.value.establishmentName,
'Price': expense.value.price,
'Category': expense.value.category,
'Payer': expense.value.payer,
'Payment Method': expense.value.paymentMethod,
'Time': expense.value.date,
'Contents': expense.value.contents
}
});
console.log('[ExpenseCreateModal] Expense created successfully:', response);
// Emit the created event
emit('created', response);
// Close the modal
dialog.value = false;
if (response.success) {
emit('created', response.data);
closeModal();
}
} catch (error: any) {
console.error('[ExpenseCreateModal] Error creating expense:', error);
// Show error message (you might want to use a toast notification here)
alert('Failed to create expense. Please try again.');
// Handle error display here if needed
} finally {
saving.value = false;
creating.value = false;
}
};
// Watch for modal open/close
watch(dialog, (newValue) => {
if (newValue) {
// Reset form when modal opens
nextTick(() => {
resetForm();
});
// Watch for modal open to set default date
watch(dialog, (isOpen) => {
if (isOpen && !expense.value.date) {
expense.value.date = new Date().toISOString().slice(0, 10);
}
});
// Initialize form with current date/time
onMounted(() => {
resetForm();
});
</script>
<style scoped>
.v-dialog > .v-card {
overflow: visible;
}
.v-form {
width: 100%;
}
.v-card-actions {
border-top: 1px solid rgba(0, 0, 0, 0.12);
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

@ -50,15 +50,16 @@ const checkForDuplicates = async () => {
try {
loading.value = true;
// Check roles with better error handling
// Check roles with better error handling - use hasAnyRole for multiple roles
const { hasAnyRole, isAdmin, isSalesOrAdmin } = useAuthorization();
let canViewDuplicates = false;
try {
canViewDuplicates = await hasRole(['sales', 'admin']);
canViewDuplicates = isSalesOrAdmin(); // Use the convenience method
console.log('[InterestDuplicateNotification] Role check result:', canViewDuplicates);
} catch (roleError) {
console.error('[InterestDuplicateNotification] Role check failed:', roleError);
// Try to get user info directly as fallback
const { isAdmin } = useAuthorization();
canViewDuplicates = isAdmin();
console.log('[InterestDuplicateNotification] Fallback admin check:', canViewDuplicates);
}

View File

@ -103,6 +103,19 @@
</template>
</v-checkbox>
<v-checkbox
v-model="options.includeReceiptContents"
color="primary"
hide-details
>
<template #label>
<div>
<div class="font-weight-medium">Include Receipt Contents</div>
<div class="text-caption text-grey-darken-1">Show receipt description/contents in detail table</div>
</div>
</template>
</v-checkbox>
<v-checkbox
v-model="options.includeProcessingFee"
color="primary"
@ -119,8 +132,21 @@
</v-card>
</v-col>
<!-- Currency Selection -->
<v-col cols="12" md="6">
<v-select
v-model="options.targetCurrency"
:items="currencyOptions"
label="Export Currency"
variant="outlined"
item-title="text"
item-value="value"
prepend-inner-icon="mdi-currency-usd"
/>
</v-col>
<!-- Page Format -->
<v-col cols="12">
<v-col cols="12" md="6">
<v-select
v-model="options.pageFormat"
:items="pageFormatOptions"
@ -204,10 +230,12 @@ interface PDFOptions {
subheader: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeReceiptContents: boolean;
includeSummary: boolean;
includeDetails: boolean;
includeProcessingFee: boolean;
pageFormat: 'A4' | 'Letter' | 'Legal';
targetCurrency: 'USD' | 'EUR';
}
// Computed dialog model
@ -225,10 +253,12 @@ const options = ref<PDFOptions>({
subheader: '',
groupBy: 'payer',
includeReceipts: true,
includeReceiptContents: true,
includeSummary: true,
includeDetails: true,
includeProcessingFee: true,
pageFormat: 'A4'
pageFormat: 'A4',
targetCurrency: 'EUR'
});
// Form options
@ -245,6 +275,11 @@ const pageFormatOptions = [
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
];
const currencyOptions = [
{ text: 'Euro (EUR)', value: 'EUR' },
{ text: 'US Dollar (USD)', value: 'USD' }
];
// Validation rules
const rules = {
required: (value: string) => !!value || 'This field is required'

View File

@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Use a cached auth state to avoid excessive API calls
const nuxtApp = useNuxtApp();
const cacheKey = 'auth:session:cache';
const cacheExpiry = 30000; // 30 seconds cache
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds)
// Check if we have a cached session
const cachedSession = nuxtApp.payload.data?.[cacheKey];
@ -44,14 +44,17 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
try {
// Check Keycloak authentication via session API with timeout
// Check Keycloak authentication via session API with timeout and retries
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout (increased from 5)
const sessionData = await $fetch('/api/auth/session', {
signal: controller.signal,
retry: 1,
retryDelay: 500
retry: 2, // Increased retry count
retryDelay: 1000, // Increased retry delay
onRetry: ({ retries }: { retries: number }) => {
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
}
}) as any;
clearTimeout(timeout);
@ -100,11 +103,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
console.error('[MIDDLEWARE] Auth check failed:', error);
// If it's a network error or timeout, check if we have a recent cached session
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') {
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
console.log('[MIDDLEWARE] Network error, checking for recent cache');
const recentCache = nuxtApp.payload.data?.[cacheKey];
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 300000) { // 5 minutes
console.log('[MIDDLEWARE] Using recent cache despite network error');
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 30 * 60 * 1000) { // 30 minutes grace period
console.log('[MIDDLEWARE] Using recent cache despite network error (age:', Math.round((now - recentCache.timestamp) / 1000), 'seconds)');
if (recentCache.authenticated && recentCache.user) {
// Store auth state for components
if (!nuxtApp.payload.data) {
@ -115,6 +118,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
authenticated: recentCache.authenticated,
groups: recentCache.groups || []
};
// Show a warning toast if cache is older than 10 minutes
if ((now - recentCache.timestamp) > 10 * 60 * 1000) {
const toast = useToast();
toast.warning('Network connectivity issue - using cached authentication');
}
return;
}
}

View File

@ -79,6 +79,7 @@ export default defineNuxtConfig({
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
@ -94,7 +95,9 @@ export default defineNuxtConfig({
}
}
}
]
],
skipWaiting: true,
clientsClaim: true
},
client: {
installPrompt: true,

75
package-lock.json generated
View File

@ -22,6 +22,7 @@
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"pdfkit": "^0.17.1",
"sharp": "^0.34.2",
"v-phone-input": "^4.4.2",
"vue": "latest",
@ -33,7 +34,8 @@
"@types/imap": "^0.8.42",
"@types/mailparser": "^3.4.6",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^6.4.17"
"@types/nodemailer": "^6.4.17",
"@types/pdfkit": "^0.14.0"
}
},
"node_modules/@ampproject/remapping": {
@ -4574,6 +4576,16 @@
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
"license": "MIT"
},
"node_modules/@types/pdfkit": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.14.0.tgz",
"integrity": "sha512-X94hoZVr9dNfV23roeXRm57AWS+AOMak3gq2wZvn4TXiLvXE8+TrYaM5IkMyZbGRw49jEqI49rP/UVL3+C3Svg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -6492,6 +6504,12 @@
"uncrypto": "^0.1.3"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -6796,9 +6814,9 @@
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -9769,6 +9787,12 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"license": "MIT"
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@ -10015,6 +10039,25 @@
"url": "https://github.com/sponsors/antonk52"
}
},
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -11346,6 +11389,19 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pdfkit": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.1.tgz",
"integrity": "sha512-Kkf1I9no14O/uo593DYph5u3QwiMfby7JsBSErN1WqeyTgCBNJE3K4pXBn3TgkdKUIVu+buSl4bYUNC+8Up4xg==",
"license": "MIT",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/peberminta": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
@ -11406,6 +11462,11 @@
"node": ">=4"
}
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -16976,9 +17037,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@ -24,6 +24,7 @@
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"pdfkit": "^0.17.1",
"sharp": "^0.34.2",
"v-phone-input": "^4.4.2",
"vue": "latest",
@ -35,6 +36,7 @@
"@types/imap": "^0.8.42",
"@types/mailparser": "^3.4.6",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^6.4.17"
"@types/nodemailer": "^6.4.17",
"@types/pdfkit": "^0.14.0"
}
}

View File

@ -291,6 +291,36 @@
v-model="showCreateModal"
@created="handleExpenseCreated"
/>
<!-- PDF Generation Loading Overlay -->
<v-overlay
:model-value="generatingPDF"
persistent
class="align-center justify-center"
>
<v-card
color="surface"
class="pa-8"
width="400"
>
<div class="text-center">
<v-progress-circular
:size="70"
:width="7"
color="primary"
indeterminate
/>
<h3 class="text-h6 mt-4 mb-2">Generating PDF...</h3>
<p class="text-body-2 text-grey-darken-1">
Your expense report is being generated with receipt images
</p>
<p class="text-caption text-grey-darken-1 mt-2">
This may take a moment for large reports
</p>
</div>
</v-card>
</v-overlay>
</div>
</template>
@ -324,6 +354,7 @@ const showDetailsModal = ref(false);
const showCreateModal = ref(false);
const selectedExpense = ref<Expense | null>(null);
const activeTab = ref<string>('');
const generatingPDF = ref(false);
// Filters
const filters = ref({
@ -413,7 +444,17 @@ const fetchExpenses = async () => {
} catch (err: any) {
console.error('[expenses] Error fetching expenses:', err);
error.value = err.message || 'Failed to fetch expenses';
// Better error messages based on status codes
if (err.statusCode === 401) {
error.value = 'Authentication required. Please refresh the page and log in again.';
} else if (err.statusCode === 403) {
error.value = 'Access denied. You need proper permissions to view expenses.';
} else if (err.statusCode === 503) {
error.value = 'Service temporarily unavailable. Please try again in a few moments.';
} else {
error.value = err.data?.message || err.message || 'Failed to fetch expenses. Please check your connection and try again.';
}
} finally {
loading.value = false;
}
@ -484,6 +525,9 @@ const exportCSV = async () => {
};
const generatePDF = async (options: any) => {
generatingPDF.value = true;
showPDFModal.value = false; // Close the modal immediately
try {
console.log('[expenses] Generating PDF with options:', options);
@ -504,30 +548,33 @@ const generatePDF = async (options: any) => {
});
if (response.success && response.data) {
// For now, create HTML file instead of PDF since we're generating HTML content
const htmlContent = atob(response.data.content); // Decode base64
const blob = new Blob([htmlContent], { type: 'text/html' });
// Decode base64 PDF content
const pdfContent = atob(response.data.content);
// Convert to byte array
const byteNumbers = new Array(pdfContent.length);
for (let i = 0; i < pdfContent.length; i++) {
byteNumbers[i] = pdfContent.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
// Create PDF blob and download
const blob = new Blob([byteArray], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${options.documentName || 'expenses'}.html`;
a.download = response.data.filename;
a.click();
window.URL.revokeObjectURL(url);
// Also open in new tab for immediate viewing
const newTab = window.open();
if (newTab) {
newTab.document.open();
newTab.document.write(htmlContent);
newTab.document.close();
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
}
}
showPDFModal.value = false;
} catch (err: any) {
console.error('[expenses] Error generating PDF:', err);
error.value = err.message || 'Failed to generate PDF';
} finally {
generatingPDF.value = false;
}
};

View File

@ -4,6 +4,8 @@ export default defineNuxtPlugin(() => {
let refreshTimer: NodeJS.Timeout | null = null
let isRefreshing = false
let retryCount = 0
const maxRetries = 3
const scheduleTokenRefresh = (expiresAt: number) => {
// Clear existing timer
@ -12,11 +14,13 @@ export default defineNuxtPlugin(() => {
refreshTimer = null
}
// Calculate time until refresh (refresh 2 minutes before expiry)
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
// Calculate time until refresh (refresh 5 minutes before expiry)
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
console.log('[AUTH_REFRESH] Token expires at:', new Date(expiresAt))
console.log('[AUTH_REFRESH] Will refresh at:', new Date(expiresAt - refreshBuffer))
// Only schedule if we have time left
if (timeUntilRefresh > 0) {
@ -28,20 +32,37 @@ export default defineNuxtPlugin(() => {
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
method: 'POST'
method: 'POST',
retry: 2,
retryDelay: 1000
})
if (response.success && response.expiresAt) {
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
retryCount = 0 // Reset retry count on success
scheduleTokenRefresh(response.expiresAt)
} else {
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
await navigateTo('/login')
}
} catch (error) {
} catch (error: any) {
console.error('[AUTH_REFRESH] Token refresh error:', error)
// If refresh fails, redirect to login
// Implement exponential backoff retry
if (retryCount < maxRetries) {
retryCount++
const retryDelay = Math.min(1000 * Math.pow(2, retryCount), 10000) // Max 10 seconds
console.log(`[AUTH_REFRESH] Retrying refresh in ${retryDelay}ms (attempt ${retryCount}/${maxRetries})`)
setTimeout(() => {
if (!isRefreshing) {
scheduleTokenRefresh(expiresAt)
}
}, retryDelay)
} else {
console.error('[AUTH_REFRESH] Max retries reached, redirecting to login')
await navigateTo('/login')
}
} finally {
isRefreshing = false
}
@ -56,11 +77,14 @@ export default defineNuxtPlugin(() => {
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
method: 'POST'
method: 'POST',
retry: 2,
retryDelay: 1000
})
if (response.success && response.expiresAt) {
console.log('[AUTH_REFRESH] Immediate refresh successful')
retryCount = 0 // Reset retry count on success
scheduleTokenRefresh(response.expiresAt)
} else {
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
@ -68,7 +92,19 @@ export default defineNuxtPlugin(() => {
}
} catch (error) {
console.error('[AUTH_REFRESH] Immediate refresh error:', error)
// Try one more time before giving up
if (retryCount === 0) {
retryCount++
console.log('[AUTH_REFRESH] Retrying immediate refresh once more...')
setTimeout(() => {
if (!isRefreshing) {
scheduleTokenRefresh(Date.now() - 1) // Force immediate refresh
}
}, 2000)
} else {
await navigateTo('/login')
}
} finally {
isRefreshing = false
}
@ -127,11 +163,21 @@ export default defineNuxtPlugin(() => {
// Listen for visibility changes to refresh when tab becomes active
if (typeof document !== 'undefined') {
let lastVisibilityChange = Date.now()
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
// Tab became visible, check if we need to refresh
const now = Date.now()
const timeSinceLastCheck = now - lastVisibilityChange
// If tab was hidden for more than 1 minute, check auth status
if (timeSinceLastCheck > 60000) {
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
checkAndScheduleRefresh()
}
lastVisibilityChange = now
}
})
}

View File

@ -57,7 +57,10 @@ export default defineEventHandler(async (event) => {
// Documenso API configuration - moved to top for use throughout
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
const templateId = '9';
const templateId = process.env.NUXT_DOCUMENSO_TEMPLATE_ID || '1';
const clientRecipientId = parseInt(process.env.NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID || '1');
const davidRecipientId = parseInt(process.env.NUXT_DOCUMENSO_DAVID_RECIPIENT_ID || '2');
const approvalRecipientId = parseInt(process.env.NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID || '3');
if (!documensoApiKey || !documensoBaseUrl) {
throw createError({
@ -231,7 +234,7 @@ export default defineEventHandler(async (event) => {
message: `Dear ${interest['Full Name']},\n\nThank you for your interest in a berth at Port Nimara. Please click the link above to sign your LOI.\n\nBest Regards,\nPort Nimara Team`,
subject: "Your LOI is ready to be signed",
redirectUrl: "https://portnimara.com",
distributionMethod: "SEQUENTIAL"
distributionMethod: "NONE"
},
title: `${interest['Full Name']}-EOI-NDA`,
externalId: `loi-${interestId}`,
@ -249,22 +252,22 @@ export default defineEventHandler(async (event) => {
},
recipients: [
{
id: 155,
id: clientRecipientId,
name: interest['Full Name'],
role: "SIGNER",
email: interest['Email Address'],
signingOrder: 1
},
{
id: 156,
id: davidRecipientId,
name: "David Mizrahi",
role: "SIGNER",
email: "dm@portnimara.com",
signingOrder: 3
},
{
id: 157,
name: "Oscar Faragher",
id: approvalRecipientId,
name: "Approval",
role: "APPROVER",
email: "sales@portnimara.com",
signingOrder: 2
@ -337,7 +340,7 @@ export default defineEventHandler(async (event) => {
} else if (recipient.email === 'dm@portnimara.com') {
signingLinks['David Mizrahi'] = recipient.signingUrl;
} else if (recipient.email === 'sales@portnimara.com') {
signingLinks['Oscar Faragher'] = recipient.signingUrl;
signingLinks['Approval'] = recipient.signingUrl;
}
}
});
@ -392,11 +395,11 @@ export default defineEventHandler(async (event) => {
updateData['EmbeddedSignatureLinkDeveloper'] = embeddedDevUrl;
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
}
if (signingLinks['Oscar Faragher']) {
updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Oscar Faragher'], 'cc');
if (signingLinks['Approval']) {
updateData['Signature Link CC'] = signingLinks['Approval'];
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Approval'], 'cc');
updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl;
console.log('[EMBEDDED] CC URL:', signingLinks['Oscar Faragher'], '-> Embedded:', embeddedCCUrl);
console.log('[EMBEDDED] CC URL:', signingLinks['Approval'], '-> Embedded:', embeddedCCUrl);
}
console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData);

View File

@ -12,6 +12,7 @@ export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { interestId } = body;
const query = getQuery(event);
console.log('[Delete Generated EOI] Interest ID:', interestId);
@ -77,7 +78,11 @@ export default defineEventHandler(async (event) => {
console.log('[Delete Generated EOI] Deleting document from Documenso');
let documensoDeleteSuccessful = false;
let retryCount = 0;
const maxRetries = 3;
// Retry logic for temporary failures
while (!documensoDeleteSuccessful && retryCount < maxRetries) {
try {
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
method: 'DELETE',
@ -87,42 +92,119 @@ export default defineEventHandler(async (event) => {
}
});
if (!deleteResponse.ok) {
const errorText = await deleteResponse.text();
console.error('[Delete Generated EOI] Documenso deletion failed:', errorText);
const responseStatus = deleteResponse.status;
let errorDetails = '';
// If it's a 404, the document is already gone, which is what we want
if (deleteResponse.status === 404) {
try {
errorDetails = await deleteResponse.text();
} catch {
errorDetails = 'No error details available';
}
if (!deleteResponse.ok) {
console.error(`[Delete Generated EOI] Documenso deletion failed (attempt ${retryCount + 1}/${maxRetries}):`, {
status: responseStatus,
statusText: deleteResponse.statusText,
details: errorDetails
});
// Handle specific status codes
switch (responseStatus) {
case 404:
// Document already deleted - this is fine
console.log('[Delete Generated EOI] Document already deleted from Documenso (404) - proceeding with database cleanup');
documensoDeleteSuccessful = true;
break;
case 403:
// Permission denied - document might be in a protected state
console.warn('[Delete Generated EOI] Permission denied (403) - document may be in a protected state');
throw createError({
statusCode: 403,
statusMessage: 'Cannot delete document - it may be fully signed or in a protected state',
});
case 500:
case 502:
case 503:
case 504:
// Server errors - retry if we haven't exceeded retries
if (retryCount < maxRetries - 1) {
console.log(`[Delete Generated EOI] Server error (${responseStatus}) - retrying in ${(retryCount + 1) * 2} seconds...`);
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 2000)); // Exponential backoff
retryCount++;
continue;
} else {
throw new Error(`Failed to delete document from Documenso: ${deleteResponse.statusText}`);
console.error('[Delete Generated EOI] Max retries exceeded for server error');
// Allow proceeding with cleanup for server errors after retries
if (query.forceCleanup === 'true') {
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding despite Documenso error');
documensoDeleteSuccessful = true;
break;
}
throw new Error(`Documenso server error after ${maxRetries} attempts (${responseStatus}): ${errorDetails}`);
}
default:
// Other errors - don't retry
throw new Error(`Documenso API error (${responseStatus}): ${errorDetails || deleteResponse.statusText}`);
}
} else {
console.log('[Delete Generated EOI] Successfully deleted document from Documenso');
documensoDeleteSuccessful = true;
}
} catch (error: any) {
console.error('[Delete Generated EOI] Documenso deletion error:', error);
console.error(`[Delete Generated EOI] Documenso deletion error (attempt ${retryCount + 1}/${maxRetries}):`, error);
// Check if it's a network error or 404 - in those cases, proceed with cleanup
if (error.message?.includes('404') || error.status === 404) {
// Network errors - retry if we haven't exceeded retries
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
if (retryCount < maxRetries - 1) {
console.log(`[Delete Generated EOI] Network error - retrying in ${(retryCount + 1) * 2} seconds...`);
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 2000));
retryCount++;
continue;
}
}
// Check if it's a 404 error wrapped in another error
if (error.message?.includes('404') || error.status === 404 || error.statusCode === 404) {
console.log('[Delete Generated EOI] Document not found in Documenso - proceeding with database cleanup');
documensoDeleteSuccessful = true;
} else {
break;
}
// Check if force cleanup is enabled
const query = getQuery(event);
if (query.forceCleanup === 'true') {
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding despite Documenso error:', error.message);
documensoDeleteSuccessful = true;
break;
}
// Don't wrap error messages multiple times
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: `Failed to delete document from Documenso: ${error.message}`,
statusMessage: error.message || 'Failed to communicate with Documenso API',
});
}
}
if (!documensoDeleteSuccessful) {
const query = getQuery(event);
if (query.forceCleanup === 'true') {
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding with database cleanup despite Documenso failure');
documensoDeleteSuccessful = true;
} else {
throw createError({
statusCode: 500,
statusMessage: 'Failed to delete document from Documenso',
statusMessage: 'Failed to delete document from Documenso after multiple attempts. You can add ?forceCleanup=true to force database cleanup.',
});
}
}
// Reset interest fields
const updateData = {

View File

@ -78,6 +78,8 @@ export default defineEventHandler(async (event) => {
* Find duplicate expenses based on multiple criteria
*/
function findDuplicateExpenses(expenses: any[]) {
console.log('[EXPENSES] Starting duplicate detection for', expenses.length, 'expenses');
const duplicateGroups: Array<{
id: string;
expenses: any[];
@ -87,6 +89,7 @@ function findDuplicateExpenses(expenses: any[]) {
}> = [];
const processedIds = new Set<number>();
let comparisons = 0;
for (let i = 0; i < expenses.length; i++) {
const expense1 = expenses[i];
@ -102,8 +105,13 @@ function findDuplicateExpenses(expenses: any[]) {
if (processedIds.has(expense2.Id)) continue;
const similarity = calculateExpenseSimilarity(expense1, expense2);
comparisons++;
if (similarity.score >= 0.8) {
console.log(`[EXPENSES] Comparing ${expense1.Id} vs ${expense2.Id}: score=${similarity.score.toFixed(3)}, threshold=0.7`);
if (similarity.score >= 0.7) { // Lower threshold for expenses
console.log(`[EXPENSES] MATCH FOUND! ${expense1.Id} vs ${expense2.Id} (score: ${similarity.score.toFixed(3)})`);
console.log('[EXPENSES] Match reasons:', similarity.reasons);
matches.push(expense2);
processedIds.add(expense2.Id);
similarity.reasons.forEach(r => matchReasons.add(r));

File diff suppressed because it is too large Load Diff

View File

@ -7,9 +7,13 @@ export default defineEventHandler(async (event) => {
console.log('[get-expenses] API called with query:', getQuery(event));
try {
// Check authentication
// Set proper headers
setHeader(event, 'Cache-Control', 'no-cache');
setHeader(event, 'Content-Type', 'application/json');
// Check authentication first
try {
await requireSalesOrAdmin(event);
console.log('[get-expenses] Authentication successful');
} catch (authError: any) {
console.error('[get-expenses] Authentication failed:', authError);
@ -127,14 +131,34 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Unable to fetch expense data. Please try again later.'
});
}
} catch (authError: any) {
if (authError.statusCode === 403) {
} catch (error: any) {
console.error('[get-expenses] Top-level error:', error);
// If it's already a proper H3 error, re-throw it
if (error.statusCode) {
throw error;
}
// Handle authentication errors specifically
if (error.message?.includes('authentication') || error.message?.includes('auth')) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
statusCode: 401,
statusMessage: 'Authentication required. Please log in again.'
});
}
throw authError;
// Handle database connection errors
if (error.message?.includes('database') || error.message?.includes('connection')) {
throw createError({
statusCode: 503,
statusMessage: 'Database temporarily unavailable. Please try again later.'
});
}
// Generic server error for anything else
throw createError({
statusCode: 500,
statusMessage: 'An unexpected error occurred. Please try again later.'
});
}
});

View File

@ -86,6 +86,9 @@ export default defineEventHandler(async (event) => {
* Find duplicate interests based on multiple criteria
*/
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
console.log('[INTERESTS] Starting duplicate detection with threshold:', threshold);
console.log('[INTERESTS] Total interests to analyze:', interests.length);
const duplicateGroups: Array<{
id: string;
interests: any[];
@ -95,6 +98,7 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
}> = [];
const processedIds = new Set<number>();
let comparisons = 0;
for (let i = 0; i < interests.length; i++) {
const interest1 = interests[i];
@ -109,14 +113,21 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
if (processedIds.has(interest2.Id)) continue;
const similarity = calculateSimilarity(interest1, interest2);
comparisons++;
console.log(`[INTERESTS] Comparing ${interest1.Id} vs ${interest2.Id}: score=${similarity.score.toFixed(3)}, threshold=${threshold}`);
if (similarity.score >= threshold) {
console.log(`[INTERESTS] MATCH FOUND! ${interest1.Id} vs ${interest2.Id} (score: ${similarity.score.toFixed(3)})`);
console.log('[INTERESTS] Match details:', similarity.details);
matches.push(interest2);
processedIds.add(interest2.Id);
}
}
if (matches.length > 1) {
console.log(`[INTERESTS] Creating duplicate group with ${matches.length} matches`);
// Mark all as processed
matches.forEach(match => processedIds.add(match.Id));
@ -138,6 +149,7 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
}
}
console.log(`[INTERESTS] Completed ${comparisons} comparisons, found ${duplicateGroups.length} duplicate groups`);
return duplicateGroups;
}
@ -147,36 +159,67 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
function calculateSimilarity(interest1: any, interest2: any) {
const scores: Array<{ type: string; score: number; weight: number }> = [];
// Email similarity (highest weight)
console.log(`[INTERESTS] Calculating similarity between:`, {
id1: interest1.Id,
name1: interest1['Full Name'],
email1: interest1['Email Address'],
phone1: interest1['Phone Number'],
id2: interest2.Id,
name2: interest2['Full Name'],
email2: interest2['Email Address'],
phone2: interest2['Phone Number']
});
// Email similarity (highest weight) - exact match required
if (interest1['Email Address'] && interest2['Email Address']) {
const emailScore = normalizeEmail(interest1['Email Address']) === normalizeEmail(interest2['Email Address']) ? 1.0 : 0.0;
scores.push({ type: 'email', score: emailScore, weight: 0.4 });
const email1 = normalizeEmail(interest1['Email Address']);
const email2 = normalizeEmail(interest2['Email Address']);
const emailScore = email1 === email2 ? 1.0 : 0.0;
scores.push({ type: 'email', score: emailScore, weight: 0.5 });
console.log(`[INTERESTS] Email comparison: "${email1}" vs "${email2}" = ${emailScore}`);
}
// Phone similarity
// Phone similarity - exact match on normalized numbers
if (interest1['Phone Number'] && interest2['Phone Number']) {
const phone1 = normalizePhone(interest1['Phone Number']);
const phone2 = normalizePhone(interest2['Phone Number']);
const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
const phoneScore = phone1 === phone2 && phone1.length >= 8 ? 1.0 : 0.0; // Require at least 8 digits
scores.push({ type: 'phone', score: phoneScore, weight: 0.4 });
console.log(`[INTERESTS] Phone comparison: "${phone1}" vs "${phone2}" = ${phoneScore}`);
}
// Name similarity
// Name similarity - fuzzy matching
if (interest1['Full Name'] && interest2['Full Name']) {
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
scores.push({ type: 'name', score: nameScore, weight: 0.2 });
scores.push({ type: 'name', score: nameScore, weight: 0.3 });
console.log(`[INTERESTS] Name comparison: "${interest1['Full Name']}" vs "${interest2['Full Name']}" = ${nameScore.toFixed(3)}`);
}
// Address similarity
if (interest1.Address && interest2.Address) {
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
scores.push({ type: 'address', score: addressScore, weight: 0.1 });
scores.push({ type: 'address', score: addressScore, weight: 0.2 });
console.log(`[INTERESTS] Address comparison: ${addressScore.toFixed(3)}`);
}
// Calculate weighted average
// Special case: if we have exact email OR phone match, give high score regardless of other fields
const hasExactEmailMatch = scores.find(s => s.type === 'email' && s.score === 1.0);
const hasExactPhoneMatch = scores.find(s => s.type === 'phone' && s.score === 1.0);
if (hasExactEmailMatch || hasExactPhoneMatch) {
console.log('[INTERESTS] Exact email or phone match found - high confidence');
return {
score: 0.95, // High confidence for exact email/phone match
details: scores
};
}
// Calculate weighted average for other cases
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
console.log(`[INTERESTS] Weighted score: ${weightedScore.toFixed(3)} (weights: ${totalWeight})`);
return {
score: weightedScore,
details: scores

View File

@ -303,6 +303,80 @@ export const convertToUSD = async (amount: number, fromCurrency: string): Promis
}
};
/**
* Convert amount from one currency to EUR
*/
export const convertToEUR = async (amount: number, fromCurrency: string): Promise<{
eurAmount: number;
rate: number;
conversionDate: string;
} | null> => {
// If already EUR, no conversion needed
if (fromCurrency.toUpperCase() === 'EUR') {
return {
eurAmount: amount,
rate: 1.0,
conversionDate: new Date().toISOString()
};
}
try {
const rateCache = await getExchangeRates();
if (!rateCache) {
console.error('[currency] No exchange rates available for conversion');
return null;
}
const fromCurrencyUpper = fromCurrency.toUpperCase();
// Get USD -> EUR rate
const usdToEurRate = rateCache.rates['EUR'];
if (!usdToEurRate) {
console.error('[currency] EUR rate not available');
return null;
}
// If converting from USD to EUR
if (fromCurrencyUpper === 'USD') {
const eurAmount = amount * usdToEurRate;
console.log(`[currency] Converted ${amount} USD to ${eurAmount.toFixed(2)} EUR (rate: ${usdToEurRate.toFixed(4)})`);
return {
eurAmount: parseFloat(eurAmount.toFixed(2)),
rate: parseFloat(usdToEurRate.toFixed(4)),
conversionDate: rateCache.lastUpdated
};
}
// For other currencies, convert through USD first
const usdToSourceRate = rateCache.rates[fromCurrencyUpper];
if (!usdToSourceRate) {
console.error(`[currency] Currency ${fromCurrencyUpper} not supported`);
return null;
}
// Calculate: Source -> USD -> EUR
// Source -> USD: amount / usdToSourceRate
// USD -> EUR: (amount / usdToSourceRate) * usdToEurRate
const sourceToEurRate = usdToEurRate / usdToSourceRate;
const eurAmount = amount * sourceToEurRate;
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${eurAmount.toFixed(2)} EUR (rate: ${sourceToEurRate.toFixed(4)})`);
return {
eurAmount: parseFloat(eurAmount.toFixed(2)),
rate: parseFloat(sourceToEurRate.toFixed(4)),
conversionDate: rateCache.lastUpdated
};
} catch (error) {
console.error('[currency] Error during EUR conversion:', error);
return null;
}
};
/**
* Format price with currency symbol
*/
@ -403,46 +477,160 @@ export const getCacheStatus = async (): Promise<{
}
};
/**
* Convert amount from any currency to target currency
*/
export const convertToTargetCurrency = async (
amount: number,
fromCurrency: string,
targetCurrency: string
): Promise<{
targetAmount: number;
rate: number;
conversionDate: string;
} | null> => {
// If same currency, no conversion needed
if (fromCurrency.toUpperCase() === targetCurrency.toUpperCase()) {
return {
targetAmount: amount,
rate: 1.0,
conversionDate: new Date().toISOString()
};
}
// Use existing functions for specific conversions
if (targetCurrency.toUpperCase() === 'USD') {
const result = await convertToUSD(amount, fromCurrency);
if (result) {
return {
targetAmount: result.usdAmount,
rate: result.rate,
conversionDate: result.conversionDate
};
}
return null;
}
if (targetCurrency.toUpperCase() === 'EUR') {
const result = await convertToEUR(amount, fromCurrency);
if (result) {
return {
targetAmount: result.eurAmount,
rate: result.rate,
conversionDate: result.conversionDate
};
}
return null;
}
// For other currencies, convert through USD
try {
const rateCache = await getExchangeRates();
if (!rateCache) {
console.error('[currency] No exchange rates available for conversion');
return null;
}
const fromCurrencyUpper = fromCurrency.toUpperCase();
const targetCurrencyUpper = targetCurrency.toUpperCase();
// Get rates
const usdToFromRate = rateCache.rates[fromCurrencyUpper];
const usdToTargetRate = rateCache.rates[targetCurrencyUpper];
if (!usdToFromRate || !usdToTargetRate) {
console.error(`[currency] Currency not supported: ${!usdToFromRate ? fromCurrencyUpper : targetCurrencyUpper}`);
return null;
}
// Calculate: Source -> USD -> Target
const fromToTargetRate = usdToTargetRate / usdToFromRate;
const targetAmount = amount * fromToTargetRate;
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${targetAmount.toFixed(2)} ${targetCurrencyUpper} (rate: ${fromToTargetRate.toFixed(4)})`);
return {
targetAmount: parseFloat(targetAmount.toFixed(2)),
rate: parseFloat(fromToTargetRate.toFixed(4)),
conversionDate: rateCache.lastUpdated
};
} catch (error) {
console.error('[currency] Error during currency conversion:', error);
return null;
}
};
/**
* Enhanced expense processing with currency conversion
*/
export const processExpenseWithCurrency = async (expense: any): Promise<any> => {
export const processExpenseWithCurrency = async (expense: any, targetCurrency: string = 'EUR'): Promise<any> => {
const processedExpense = { ...expense };
// Parse price number
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
processedExpense.PriceNumber = priceNumber;
// Get currency symbol
// Get currency code and symbol
const currencyCode = expense.currency || 'USD';
processedExpense.Currency = currencyCode;
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
// Convert to USD if not already USD
if (currencyCode.toUpperCase() !== 'USD') {
const conversion = await convertToUSD(priceNumber, currencyCode);
// Convert to target currency if not already in target
const targetCurrencyUpper = targetCurrency.toUpperCase();
const targetField = `Price${targetCurrencyUpper}`;
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
if (conversion) {
processedExpense.PriceUSD = conversion.usdAmount;
processedExpense[targetField] = conversion.targetAmount;
processedExpense.ConversionRate = conversion.rate;
processedExpense.ConversionDate = conversion.conversionDate;
processedExpense.TargetCurrency = targetCurrencyUpper;
}
} else {
// If already USD, set USD amount to original amount
processedExpense.PriceUSD = priceNumber;
// If already in target currency, set target amount to original amount
processedExpense[targetField] = priceNumber;
processedExpense.ConversionRate = 1.0;
processedExpense.ConversionDate = new Date().toISOString();
processedExpense.TargetCurrency = targetCurrencyUpper;
}
// Also convert to USD and EUR for compatibility
if (currencyCode.toUpperCase() !== 'USD') {
const usdConversion = await convertToUSD(priceNumber, currencyCode);
if (usdConversion) {
processedExpense.PriceUSD = usdConversion.usdAmount;
}
} else {
processedExpense.PriceUSD = priceNumber;
}
if (currencyCode.toUpperCase() !== 'EUR') {
const eurConversion = await convertToEUR(priceNumber, currencyCode);
if (eurConversion) {
processedExpense.PriceEUR = eurConversion.eurAmount;
}
} else {
processedExpense.PriceEUR = priceNumber;
}
// Create display prices
processedExpense.DisplayPrice = createDisplayPrice(
priceNumber,
currencyCode,
processedExpense.PriceUSD
);
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
processedExpense.PriceUSD || priceNumber,
'USD'
// Create display price with target currency conversion
const targetAmount = processedExpense[targetField];
if (currencyCode.toUpperCase() !== targetCurrencyUpper && targetAmount) {
const targetSymbol = getCurrencySymbol(targetCurrency);
processedExpense.DisplayPriceWithTarget = `${formatPriceWithCurrency(priceNumber, currencyCode)} (${targetSymbol}${targetAmount.toFixed(2)})`;
} else {
processedExpense.DisplayPriceWithTarget = formatPriceWithCurrency(priceNumber, currencyCode);
}
processedExpense.DisplayPriceTarget = formatPriceWithCurrency(
targetAmount || priceNumber,
targetCurrency
);
return processedExpense;