feat: add email ingestion notification handler with logging (refs #159)
- Extract all notification logic from EmailIngestionService into dedicated EmailIngestionNotificationHandler class - Add notification_logs entries for every email sent (success/failure) - Add in-app user_notifications for all error scenarios (no vehicles, no attachments, OCR failure, processing failure) - Update email templates with enhanced variables: merchantName, totalAmount, date, guidance - Update pending vehicle notification title to 'Vehicle Selection Required' - Add sample variables for receipt templates in test email flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Core processing service for the email-to-record pipeline
|
* @ai-summary Core processing service for the email-to-record pipeline
|
||||||
* @ai-context Orchestrates sender validation, OCR extraction, record classification,
|
* @ai-context Orchestrates sender validation, OCR extraction, record classification,
|
||||||
* vehicle association, status tracking, retry logic, and reply emails
|
* vehicle association, status tracking, and retry logic. Delegates all notifications
|
||||||
|
* (emails, in-app, logging) to EmailIngestionNotificationHandler.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
@@ -17,6 +18,7 @@ import { EmailService } from '../../notifications/domain/email.service';
|
|||||||
import { ocrService } from '../../ocr/domain/ocr.service';
|
import { ocrService } from '../../ocr/domain/ocr.service';
|
||||||
import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types';
|
import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types';
|
||||||
import { ReceiptClassifier } from './receipt-classifier';
|
import { ReceiptClassifier } from './receipt-classifier';
|
||||||
|
import { EmailIngestionNotificationHandler } from './notification-handler';
|
||||||
import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service';
|
import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service';
|
||||||
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
|
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
|
||||||
import { FuelType } from '../../fuel-logs/domain/fuel-logs.types';
|
import { FuelType } from '../../fuel-logs/domain/fuel-logs.types';
|
||||||
@@ -56,9 +58,7 @@ export class EmailIngestionService {
|
|||||||
private resendClient: ResendInboundClient;
|
private resendClient: ResendInboundClient;
|
||||||
private userProfileRepository: UserProfileRepository;
|
private userProfileRepository: UserProfileRepository;
|
||||||
private vehiclesRepository: VehiclesRepository;
|
private vehiclesRepository: VehiclesRepository;
|
||||||
private notificationsRepository: NotificationsRepository;
|
private notificationHandler: EmailIngestionNotificationHandler;
|
||||||
private templateService: TemplateService;
|
|
||||||
private emailService: EmailService;
|
|
||||||
private classifier: ReceiptClassifier;
|
private classifier: ReceiptClassifier;
|
||||||
private fuelLogsService: FuelLogsService;
|
private fuelLogsService: FuelLogsService;
|
||||||
private maintenanceService: MaintenanceService;
|
private maintenanceService: MaintenanceService;
|
||||||
@@ -69,9 +69,12 @@ export class EmailIngestionService {
|
|||||||
this.resendClient = new ResendInboundClient();
|
this.resendClient = new ResendInboundClient();
|
||||||
this.userProfileRepository = new UserProfileRepository(p);
|
this.userProfileRepository = new UserProfileRepository(p);
|
||||||
this.vehiclesRepository = new VehiclesRepository(p);
|
this.vehiclesRepository = new VehiclesRepository(p);
|
||||||
this.notificationsRepository = new NotificationsRepository(p);
|
const notificationsRepository = new NotificationsRepository(p);
|
||||||
this.templateService = new TemplateService();
|
this.notificationHandler = new EmailIngestionNotificationHandler(
|
||||||
this.emailService = new EmailService();
|
notificationsRepository,
|
||||||
|
new TemplateService(),
|
||||||
|
new EmailService(),
|
||||||
|
);
|
||||||
this.classifier = new ReceiptClassifier();
|
this.classifier = new ReceiptClassifier();
|
||||||
this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p));
|
this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p));
|
||||||
this.maintenanceService = new MaintenanceService();
|
this.maintenanceService = new MaintenanceService();
|
||||||
@@ -96,7 +99,7 @@ export class EmailIngestionService {
|
|||||||
// 2. Validate sender
|
// 2. Validate sender
|
||||||
const userProfile = await this.validateSender(senderEmail);
|
const userProfile = await this.validateSender(senderEmail);
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
await this.handleUnregisteredSender(emailId, senderEmail, subject);
|
await this.handleUnregisteredSender(emailId, senderEmail);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ export class EmailIngestionService {
|
|||||||
// 4. Filter valid attachments
|
// 4. Filter valid attachments
|
||||||
const validAttachments = this.filterAttachments(attachments);
|
const validAttachments = this.filterAttachments(attachments);
|
||||||
if (validAttachments.length === 0) {
|
if (validAttachments.length === 0) {
|
||||||
await this.handleNoValidAttachments(emailId, senderEmail, userName, subject);
|
await this.handleNoValidAttachments(emailId, userId, userName, senderEmail);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +132,7 @@ export class EmailIngestionService {
|
|||||||
userId, validAttachments, emailClassification, emailId
|
userId, validAttachments, emailClassification, emailId
|
||||||
);
|
);
|
||||||
if (!ocrResult) {
|
if (!ocrResult) {
|
||||||
await this.handleOcrFailure(emailId, senderEmail, userName, subject, 'No receipt data could be extracted from attachments');
|
await this.handleOcrFailure(emailId, userId, userName, senderEmail, 'No receipt data could be extracted from attachments');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,9 +150,6 @@ export class EmailIngestionService {
|
|||||||
processingResult,
|
processingResult,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 10. Send confirmation email
|
|
||||||
await this.sendConfirmationEmail(senderEmail, userName, processingResult);
|
|
||||||
|
|
||||||
logger.info('Email processing completed successfully', {
|
logger.info('Email processing completed successfully', {
|
||||||
emailId,
|
emailId,
|
||||||
userId,
|
userId,
|
||||||
@@ -463,7 +463,7 @@ export class EmailIngestionService {
|
|||||||
|
|
||||||
// No vehicles: user must add a vehicle first
|
// No vehicles: user must add a vehicle first
|
||||||
if (vehicles.length === 0) {
|
if (vehicles.length === 0) {
|
||||||
await this.sendNoVehiclesEmail(userEmail, userName);
|
await this.notificationHandler.notifyNoVehicles(userId, userName, userEmail);
|
||||||
return {
|
return {
|
||||||
recordType,
|
recordType,
|
||||||
vehicleId: null,
|
vehicleId: null,
|
||||||
@@ -476,39 +476,38 @@ export class EmailIngestionService {
|
|||||||
|
|
||||||
// Single vehicle: auto-associate and create record
|
// Single vehicle: auto-associate and create record
|
||||||
if (vehicles.length === 1) {
|
if (vehicles.length === 1) {
|
||||||
const vehicleId = vehicles[0].id;
|
const vehicle = vehicles[0];
|
||||||
let recordId: string | null = null;
|
let recordId: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
recordId = await this.createRecord(userId, vehicleId, recordType, extractedData);
|
recordId = await this.createRecord(userId, vehicle.id, recordType, extractedData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to create record from email receipt', {
|
logger.error('Failed to create record from email receipt', {
|
||||||
userId,
|
userId,
|
||||||
vehicleId,
|
vehicleId: vehicle.id,
|
||||||
recordType,
|
recordType,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
// Record creation failed but association succeeded; notify user of partial success
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordLabel = recordType === 'fuel_log' ? 'fuel' : 'maintenance';
|
const vehicleName = vehicle.nickname
|
||||||
const message = recordId
|
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|
||||||
? `Your emailed ${recordLabel} receipt has been processed and a ${recordLabel} record was created for your vehicle.`
|
|| 'your vehicle';
|
||||||
: `Your emailed ${recordLabel} receipt was processed but the record could not be created automatically. Please add it manually.`;
|
|
||||||
|
|
||||||
await this.notificationsRepository.insertUserNotification({
|
await this.notificationHandler.notifyReceiptProcessed({
|
||||||
userId,
|
userId,
|
||||||
notificationType: 'email_ingestion',
|
userName,
|
||||||
title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed',
|
userEmail,
|
||||||
message,
|
vehicleName,
|
||||||
referenceType: recordType,
|
recordType,
|
||||||
referenceId: recordId ?? undefined,
|
recordId,
|
||||||
vehicleId,
|
vehicleId: vehicle.id,
|
||||||
|
extractedData,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recordType,
|
recordType,
|
||||||
vehicleId,
|
vehicleId: vehicle.id,
|
||||||
recordId,
|
recordId,
|
||||||
documentId: null,
|
documentId: null,
|
||||||
pendingAssociationId: null,
|
pendingAssociationId: null,
|
||||||
@@ -524,17 +523,15 @@ export class EmailIngestionService {
|
|||||||
documentId: null,
|
documentId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.notificationsRepository.insertUserNotification({
|
await this.notificationHandler.notifyPendingVehicleSelection({
|
||||||
userId,
|
userId,
|
||||||
notificationType: 'email_ingestion',
|
userName,
|
||||||
title: 'Receipt Pending Vehicle Selection',
|
userEmail,
|
||||||
message: `Your emailed receipt has been processed. Please select which vehicle this ${recordType === 'fuel_log' ? 'fuel' : 'maintenance'} receipt belongs to.`,
|
recordType,
|
||||||
referenceType: 'pending_vehicle_association',
|
pendingAssociationId: pendingAssociation.id,
|
||||||
referenceId: pendingAssociation.id,
|
extractedData,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.sendPendingVehicleEmail(userEmail, userName, recordType, extractedData);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recordType,
|
recordType,
|
||||||
vehicleId: null,
|
vehicleId: null,
|
||||||
@@ -681,13 +678,13 @@ export class EmailIngestionService {
|
|||||||
private async handleProcessingError(
|
private async handleProcessingError(
|
||||||
emailId: string,
|
emailId: string,
|
||||||
senderEmail: string,
|
senderEmail: string,
|
||||||
subject: string | null,
|
_subject: string | null,
|
||||||
error: unknown
|
error: unknown
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
logger.error('Email processing pipeline error', { emailId, error: errorMessage });
|
logger.error('Email processing pipeline error', { emailId, error: errorMessage });
|
||||||
|
|
||||||
// Get current queue entry for retry count
|
// Get current queue entry for retry count and userId
|
||||||
const queueEntry = await this.repository.getQueueEntry(emailId);
|
const queueEntry = await this.repository.getQueueEntry(emailId);
|
||||||
const currentRetryCount = queueEntry?.retryCount || 0;
|
const currentRetryCount = queueEntry?.retryCount || 0;
|
||||||
const newRetryCount = currentRetryCount + 1;
|
const newRetryCount = currentRetryCount + 1;
|
||||||
@@ -711,11 +708,15 @@ export class EmailIngestionService {
|
|||||||
retryCount: newRetryCount,
|
retryCount: newRetryCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send failure email to user
|
// Send failure notification (email + in-app if userId available)
|
||||||
await this.sendFailureEmail(senderEmail, subject, errorMessage).catch(emailErr => {
|
await this.notificationHandler.notifyProcessingFailure({
|
||||||
logger.error('Failed to send failure notification email', {
|
userId: queueEntry?.userId,
|
||||||
|
userEmail: senderEmail,
|
||||||
|
errorReason: errorMessage,
|
||||||
|
}).catch(notifyErr => {
|
||||||
|
logger.error('Failed to send failure notification', {
|
||||||
emailId,
|
emailId,
|
||||||
error: emailErr instanceof Error ? emailErr.message : String(emailErr),
|
error: notifyErr instanceof Error ? notifyErr.message : String(notifyErr),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -723,8 +724,7 @@ export class EmailIngestionService {
|
|||||||
|
|
||||||
private async handleUnregisteredSender(
|
private async handleUnregisteredSender(
|
||||||
emailId: string,
|
emailId: string,
|
||||||
senderEmail: string,
|
senderEmail: string
|
||||||
subject: string | null
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.info('Unregistered sender rejected', { emailId, senderEmail });
|
logger.info('Unregistered sender rejected', { emailId, senderEmail });
|
||||||
|
|
||||||
@@ -732,12 +732,7 @@ export class EmailIngestionService {
|
|||||||
errorMessage: 'Sender email is not registered with MotoVaultPro',
|
errorMessage: 'Sender email is not registered with MotoVaultPro',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send rejection email
|
await this.notificationHandler.notifyUnregisteredSender(senderEmail).catch(error => {
|
||||||
await this.sendFailureEmail(
|
|
||||||
senderEmail,
|
|
||||||
subject,
|
|
||||||
'This email address is not registered with MotoVaultPro. Please send receipts from the email address associated with your account.'
|
|
||||||
).catch(error => {
|
|
||||||
logger.error('Failed to send unregistered sender notification', {
|
logger.error('Failed to send unregistered sender notification', {
|
||||||
emailId,
|
emailId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -747,9 +742,9 @@ export class EmailIngestionService {
|
|||||||
|
|
||||||
private async handleNoValidAttachments(
|
private async handleNoValidAttachments(
|
||||||
emailId: string,
|
emailId: string,
|
||||||
senderEmail: string,
|
userId: string,
|
||||||
_userName: string,
|
userName: string,
|
||||||
subject: string | null
|
userEmail: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.info('No valid attachments found', { emailId });
|
logger.info('No valid attachments found', { emailId });
|
||||||
|
|
||||||
@@ -757,11 +752,7 @@ export class EmailIngestionService {
|
|||||||
errorMessage: 'No valid attachments found. Supported types: PDF, PNG, JPG, JPEG, HEIC (max 10MB each)',
|
errorMessage: 'No valid attachments found. Supported types: PDF, PNG, JPG, JPEG, HEIC (max 10MB each)',
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.sendFailureEmail(
|
await this.notificationHandler.notifyNoValidAttachments(userId, userName, userEmail).catch(error => {
|
||||||
senderEmail,
|
|
||||||
subject,
|
|
||||||
'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.'
|
|
||||||
).catch(error => {
|
|
||||||
logger.error('Failed to send no-attachments notification', {
|
logger.error('Failed to send no-attachments notification', {
|
||||||
emailId,
|
emailId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
@@ -771,9 +762,9 @@ export class EmailIngestionService {
|
|||||||
|
|
||||||
private async handleOcrFailure(
|
private async handleOcrFailure(
|
||||||
emailId: string,
|
emailId: string,
|
||||||
senderEmail: string,
|
userId: string,
|
||||||
_userName: string,
|
userName: string,
|
||||||
subject: string | null,
|
userEmail: string,
|
||||||
reason: string
|
reason: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
logger.info('OCR extraction failed for all attachments', { emailId, reason });
|
logger.info('OCR extraction failed for all attachments', { emailId, reason });
|
||||||
@@ -782,148 +773,11 @@ export class EmailIngestionService {
|
|||||||
errorMessage: reason,
|
errorMessage: reason,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.sendFailureEmail(senderEmail, subject, reason).catch(error => {
|
await this.notificationHandler.notifyOcrFailure(userId, userName, userEmail, reason).catch(error => {
|
||||||
logger.error('Failed to send OCR failure notification', {
|
logger.error('Failed to send OCR failure notification', {
|
||||||
emailId,
|
emailId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================
|
|
||||||
// Email Replies
|
|
||||||
// ========================
|
|
||||||
|
|
||||||
private async sendConfirmationEmail(
|
|
||||||
recipientEmail: string,
|
|
||||||
userName: string,
|
|
||||||
result: EmailProcessingResult
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_processed');
|
|
||||||
if (!template || !template.isActive) {
|
|
||||||
logger.warn('receipt_processed template not found or inactive');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordTypeDisplay = result.recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
|
|
||||||
const variables = {
|
|
||||||
userName,
|
|
||||||
vehicleName: result.vehicleId ? 'your vehicle' : 'Pending Selection',
|
|
||||||
recordType: recordTypeDisplay,
|
|
||||||
receiptDate: result.extractedData.date || 'N/A',
|
|
||||||
amount: result.extractedData.total?.toFixed(2) || 'N/A',
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderedSubject = this.templateService.render(template.subject, variables);
|
|
||||||
const renderedHtml = this.templateService.renderEmailHtml(template.body, variables);
|
|
||||||
|
|
||||||
await this.emailService.send(recipientEmail, renderedSubject, renderedHtml);
|
|
||||||
|
|
||||||
logger.info('Confirmation email sent', { recipientEmail });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to send confirmation email', {
|
|
||||||
recipientEmail,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendFailureEmail(
|
|
||||||
recipientEmail: string,
|
|
||||||
emailSubject: string | null,
|
|
||||||
errorReason: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_failed');
|
|
||||||
if (!template || !template.isActive) {
|
|
||||||
logger.warn('receipt_failed template not found or inactive');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variables = {
|
|
||||||
userName: 'MotoVaultPro User',
|
|
||||||
emailSubject: emailSubject || '(no subject)',
|
|
||||||
errorReason,
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderedSubject = this.templateService.render(template.subject, variables);
|
|
||||||
const renderedHtml = this.templateService.renderEmailHtml(template.body, variables);
|
|
||||||
|
|
||||||
await this.emailService.send(recipientEmail, renderedSubject, renderedHtml);
|
|
||||||
|
|
||||||
logger.info('Failure email sent', { recipientEmail });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to send failure email', {
|
|
||||||
recipientEmail,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendNoVehiclesEmail(
|
|
||||||
recipientEmail: string,
|
|
||||||
userName: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_failed');
|
|
||||||
if (!template || !template.isActive) {
|
|
||||||
logger.warn('receipt_failed template not found or inactive');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variables = {
|
|
||||||
userName,
|
|
||||||
emailSubject: 'Receipt Submission',
|
|
||||||
errorReason: 'You do not have any vehicles registered in MotoVaultPro. Please add a vehicle first, then re-send your receipt.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderedSubject = this.templateService.render(template.subject, variables);
|
|
||||||
const renderedHtml = this.templateService.renderEmailHtml(template.body, variables);
|
|
||||||
|
|
||||||
await this.emailService.send(recipientEmail, renderedSubject, renderedHtml);
|
|
||||||
|
|
||||||
logger.info('No-vehicles error email sent', { recipientEmail });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to send no-vehicles error email', {
|
|
||||||
recipientEmail,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendPendingVehicleEmail(
|
|
||||||
recipientEmail: string,
|
|
||||||
userName: string,
|
|
||||||
recordType: EmailRecordType,
|
|
||||||
extractedData: ExtractedReceiptData
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const template = await this.notificationsRepository.getEmailTemplateByKey('receipt_pending_vehicle');
|
|
||||||
if (!template || !template.isActive) {
|
|
||||||
logger.warn('receipt_pending_vehicle template not found or inactive');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordTypeDisplay = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
|
|
||||||
const variables = {
|
|
||||||
userName,
|
|
||||||
recordType: recordTypeDisplay,
|
|
||||||
receiptDate: extractedData.date || 'N/A',
|
|
||||||
amount: extractedData.total?.toFixed(2) || 'N/A',
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderedSubject = this.templateService.render(template.subject, variables);
|
|
||||||
const renderedHtml = this.templateService.renderEmailHtml(template.body, variables);
|
|
||||||
|
|
||||||
await this.emailService.send(recipientEmail, renderedSubject, renderedHtml);
|
|
||||||
|
|
||||||
logger.info('Pending vehicle email sent', { recipientEmail });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to send pending vehicle email', {
|
|
||||||
recipientEmail,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* @ai-summary Notification handler for the email ingestion pipeline
|
||||||
|
* @ai-context Encapsulates all email replies, in-app notifications, and notification logging
|
||||||
|
* for the email-to-record flow. Every email sent is logged to notification_logs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../../../core/logging/logger';
|
||||||
|
import { NotificationsRepository } from '../../notifications/data/notifications.repository';
|
||||||
|
import { TemplateService } from '../../notifications/domain/template.service';
|
||||||
|
import { EmailService } from '../../notifications/domain/email.service';
|
||||||
|
import type { TemplateKey } from '../../notifications/domain/notifications.types';
|
||||||
|
import type { EmailRecordType, ExtractedReceiptData } from './email-ingestion.types';
|
||||||
|
|
||||||
|
export class EmailIngestionNotificationHandler {
|
||||||
|
constructor(
|
||||||
|
private notificationsRepository: NotificationsRepository,
|
||||||
|
private templateService: TemplateService,
|
||||||
|
private emailService: EmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Success Notifications
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify user that their emailed receipt was successfully processed.
|
||||||
|
* Sends confirmation email + creates in-app notification + logs to notification_logs.
|
||||||
|
*/
|
||||||
|
async notifyReceiptProcessed(params: {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
vehicleName: string;
|
||||||
|
recordType: EmailRecordType;
|
||||||
|
recordId: string | null;
|
||||||
|
vehicleId: string;
|
||||||
|
extractedData: ExtractedReceiptData;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { userId, userName, userEmail, vehicleName, recordType, recordId, vehicleId, extractedData } = params;
|
||||||
|
const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
|
||||||
|
const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown';
|
||||||
|
|
||||||
|
// In-app notification
|
||||||
|
const message = recordId
|
||||||
|
? `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} has been processed and recorded for ${vehicleName}.`
|
||||||
|
: `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} was processed but the record could not be created automatically. Please add it manually.`;
|
||||||
|
|
||||||
|
await this.notificationsRepository.insertUserNotification({
|
||||||
|
userId,
|
||||||
|
notificationType: 'email_ingestion',
|
||||||
|
title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed',
|
||||||
|
message,
|
||||||
|
referenceType: recordType,
|
||||||
|
referenceId: recordId ?? undefined,
|
||||||
|
vehicleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirmation email
|
||||||
|
await this.sendTemplateEmail({
|
||||||
|
templateKey: 'receipt_processed',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
variables: {
|
||||||
|
userName,
|
||||||
|
vehicleName,
|
||||||
|
recordType: recordLabel,
|
||||||
|
merchantName,
|
||||||
|
totalAmount: extractedData.total?.toFixed(2) || 'N/A',
|
||||||
|
date: extractedData.date || 'N/A',
|
||||||
|
},
|
||||||
|
referenceType: 'email_ingestion',
|
||||||
|
referenceId: recordId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Pending Vehicle Notification
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify multi-vehicle user that their receipt needs vehicle selection.
|
||||||
|
* Sends pending-vehicle email + creates in-app notification + logs to notification_logs.
|
||||||
|
*/
|
||||||
|
async notifyPendingVehicleSelection(params: {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
recordType: EmailRecordType;
|
||||||
|
pendingAssociationId: string;
|
||||||
|
extractedData: ExtractedReceiptData;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { userId, userName, userEmail, recordType, pendingAssociationId, extractedData } = params;
|
||||||
|
const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
|
||||||
|
const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown';
|
||||||
|
|
||||||
|
// In-app notification
|
||||||
|
await this.notificationsRepository.insertUserNotification({
|
||||||
|
userId,
|
||||||
|
notificationType: 'email_ingestion',
|
||||||
|
title: 'Vehicle Selection Required',
|
||||||
|
message: `Your emailed receipt from ${merchantName} has been processed. Please select which vehicle this ${recordLabel.toLowerCase()} belongs to.`,
|
||||||
|
referenceType: 'pending_vehicle_association',
|
||||||
|
referenceId: pendingAssociationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pending vehicle email
|
||||||
|
await this.sendTemplateEmail({
|
||||||
|
templateKey: 'receipt_pending_vehicle',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
variables: {
|
||||||
|
userName,
|
||||||
|
recordType: recordLabel,
|
||||||
|
merchantName,
|
||||||
|
totalAmount: extractedData.total?.toFixed(2) || 'N/A',
|
||||||
|
date: extractedData.date || 'N/A',
|
||||||
|
},
|
||||||
|
referenceType: 'email_ingestion',
|
||||||
|
referenceId: pendingAssociationId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Error Notifications
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify unregistered sender that their email was rejected.
|
||||||
|
* Email reply only (no in-app notification since no user account).
|
||||||
|
*/
|
||||||
|
async notifyUnregisteredSender(userEmail: string): Promise<void> {
|
||||||
|
await this.sendTemplateEmail({
|
||||||
|
templateKey: 'receipt_failed',
|
||||||
|
userEmail,
|
||||||
|
variables: {
|
||||||
|
userName: 'MotoVaultPro User',
|
||||||
|
errorReason: 'This email address is not registered with MotoVaultPro.',
|
||||||
|
guidance: 'Please send receipts from the email address associated with your account. You can check your registered email in your MotoVaultPro profile settings.',
|
||||||
|
},
|
||||||
|
referenceType: 'email_ingestion',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify user that they must add a vehicle before emailing receipts.
|
||||||
|
* Sends error email + creates in-app notification + logs to notification_logs.
|
||||||
|
*/
|
||||||
|
async notifyNoVehicles(userId: string, userName: string, userEmail: string): Promise<void> {
|
||||||
|
// In-app notification
|
||||||
|
await this.notificationsRepository.insertUserNotification({
|
||||||
|
userId,
|
||||||
|
notificationType: 'email_ingestion',
|
||||||
|
title: 'Receipt Processing Failed',
|
||||||
|
message: 'Your emailed receipt could not be processed because you have no vehicles registered. Please add a vehicle first, then re-send your receipt.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error email
|
||||||
|
await this.sendTemplateEmail({
|
||||||
|
templateKey: 'receipt_failed',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
variables: {
|
||||||
|
userName,
|
||||||
|
errorReason: 'You do not have any vehicles registered in MotoVaultPro.',
|
||||||
|
guidance: 'Please add a vehicle first in the MotoVaultPro app, then re-send your receipt.',
|
||||||
|
},
|
||||||
|
referenceType: 'email_ingestion',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify user that no valid attachments were found in their email.
|
||||||
|
* Sends error email + creates in-app notification + logs to notification_logs.
|
||||||
|
*/
|
||||||
|
async notifyNoValidAttachments(userId: string, userName: string, userEmail: string): Promise<void> {
|
||||||
|
// In-app notification
|
||||||
|
await this.notificationsRepository.insertUserNotification({
|
||||||
|
userId,
|
||||||
|
notificationType: 'email_ingestion',
|
||||||
|
title: 'Receipt Processing Failed',
|
||||||
|
message: 'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error email
|
||||||
|
await this.sendTemplateEmail({
|
||||||
|
templateKey: 'receipt_failed',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
variables: {
|
||||||
|
userName,
|
||||||
|
errorReason: 'No valid attachments were found in your email.',
|
||||||
|
guidance: 'Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB. Make sure your receipt is clearly visible in the image.',
|
||||||
|
},
|
||||||
|
referenceType: 'email_ingestion',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify user that OCR extraction failed after all attempts.
|
||||||
|
* Sends error email + creates in-app notification + logs to notification_logs.
|
||||||
|
*/
|
||||||
|
async notifyOcrFailure(userId: string, userName: string, userEmail: string, reason: string): Promise<void> {
|
||||||
|
// In-app notification
|
||||||
|
await this.notificationsRepository.insertUserNotification({
|
||||||
|
userId,
|
||||||
|
notificationType: 'email_ingestion',
|
||||||
|
title: 'Receipt Processing Failed',
|
||||||
|
message: `We could not extract data from your receipt: ${reason}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error email
|
||||||
|
await this.sendTemplateEmail({
|
||||||
|
templateKey: 'receipt_failed',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
variables: {
|
||||||
|
userName,
|
||||||
|
errorReason: reason,
|
||||||
|
guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.',
|
||||||
|
},
|
||||||
|
referenceType: 'email_ingestion',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify user of a general processing failure (after max retries exceeded).
|
||||||
|
* Sends error email + creates in-app notification (if userId available) + logs.
|
||||||
|
*/
|
||||||
|
async notifyProcessingFailure(params: {
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
userEmail: string;
|
||||||
|
errorReason: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { userId, userName, userEmail, errorReason } = params;
|
||||||
|
|
||||||
|
// In-app notification (only if we have a userId)
|
||||||
|
if (userId) {
|
||||||
|
await this.notificationsRepository.insertUserNotification({
|
||||||
|
userId,
|
||||||
|
notificationType: 'email_ingestion',
|
||||||
|
title: 'Receipt Processing Failed',
|
||||||
|
message: `Your emailed receipt could not be processed: ${errorReason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error email
|
||||||
|
await this.sendTemplateEmail({
|
||||||
|
templateKey: 'receipt_failed',
|
||||||
|
userId,
|
||||||
|
userEmail,
|
||||||
|
variables: {
|
||||||
|
userName: userName || 'MotoVaultPro User',
|
||||||
|
errorReason,
|
||||||
|
guidance: 'Please try again or upload the receipt directly through the MotoVaultPro app.',
|
||||||
|
},
|
||||||
|
referenceType: 'email_ingestion',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Internal Helpers
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a templated email and log to notification_logs.
|
||||||
|
* Swallows errors to prevent notification failures from breaking the pipeline.
|
||||||
|
*/
|
||||||
|
private async sendTemplateEmail(params: {
|
||||||
|
templateKey: TemplateKey;
|
||||||
|
userId?: string;
|
||||||
|
userEmail: string;
|
||||||
|
variables: Record<string, string | number | boolean | null | undefined>;
|
||||||
|
referenceType?: string;
|
||||||
|
referenceId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { templateKey, userId, userEmail, variables, referenceType, referenceId } = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = await this.notificationsRepository.getEmailTemplateByKey(templateKey);
|
||||||
|
if (!template || !template.isActive) {
|
||||||
|
logger.warn('Email template not found or inactive', { templateKey });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedSubject = this.templateService.render(template.subject, variables);
|
||||||
|
const renderedHtml = this.templateService.renderEmailHtml(template.body, variables);
|
||||||
|
|
||||||
|
await this.emailService.send(userEmail, renderedSubject, renderedHtml);
|
||||||
|
|
||||||
|
// Log successful send
|
||||||
|
if (userId) {
|
||||||
|
await this.notificationsRepository.insertNotificationLog({
|
||||||
|
user_id: userId,
|
||||||
|
notification_type: 'email',
|
||||||
|
template_key: templateKey,
|
||||||
|
recipient_email: userEmail,
|
||||||
|
subject: renderedSubject,
|
||||||
|
reference_type: referenceType,
|
||||||
|
reference_id: referenceId,
|
||||||
|
status: 'sent',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Email ingestion notification sent', { templateKey, userEmail });
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error('Failed to send email ingestion notification', {
|
||||||
|
templateKey,
|
||||||
|
userEmail,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log failed send
|
||||||
|
if (userId) {
|
||||||
|
await this.notificationsRepository.insertNotificationLog({
|
||||||
|
user_id: userId,
|
||||||
|
notification_type: 'email',
|
||||||
|
template_key: templateKey,
|
||||||
|
recipient_email: userEmail,
|
||||||
|
reference_type: referenceType,
|
||||||
|
reference_id: referenceId,
|
||||||
|
status: 'failed',
|
||||||
|
error_message: errorMessage,
|
||||||
|
}).catch(logErr => {
|
||||||
|
logger.error('Failed to log notification failure', {
|
||||||
|
error: logErr instanceof Error ? logErr.message : String(logErr),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,14 +31,15 @@ Your emailed receipt has been successfully processed.
|
|||||||
|
|
||||||
Vehicle: {{vehicleName}}
|
Vehicle: {{vehicleName}}
|
||||||
Record Type: {{recordType}}
|
Record Type: {{recordType}}
|
||||||
Date: {{receiptDate}}
|
Merchant: {{merchantName}}
|
||||||
Amount: ${{amount}}
|
Date: {{date}}
|
||||||
|
Amount: ${{totalAmount}}
|
||||||
|
|
||||||
The record has been added to your vehicle history.
|
The record has been added to your vehicle history.
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
MotoVaultPro Team',
|
MotoVaultPro Team',
|
||||||
'["userName", "vehicleName", "recordType", "receiptDate", "amount"]',
|
'["userName", "vehicleName", "recordType", "merchantName", "totalAmount", "date"]',
|
||||||
'<!DOCTYPE html>
|
'<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -65,8 +66,9 @@ MotoVaultPro Team',
|
|||||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #2e7d32;">
|
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #2e7d32;">
|
||||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Vehicle:</strong> {{vehicleName}}</p>
|
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Vehicle:</strong> {{vehicleName}}</p>
|
||||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
|
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
|
||||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{receiptDate}}</p>
|
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
|
||||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{amount}}</p>
|
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
|
||||||
|
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -94,19 +96,13 @@ MotoVaultPro Team',
|
|||||||
|
|
||||||
We were unable to process the receipt you emailed to us.
|
We were unable to process the receipt you emailed to us.
|
||||||
|
|
||||||
Subject: {{emailSubject}}
|
|
||||||
Error: {{errorReason}}
|
Error: {{errorReason}}
|
||||||
|
|
||||||
Please try again with a clearer image or PDF, or upload the receipt directly through the app.
|
{{guidance}}
|
||||||
|
|
||||||
Tips for better results:
|
|
||||||
- Use a well-lit, high-contrast photo
|
|
||||||
- Ensure text is legible and not cut off
|
|
||||||
- PDF receipts work best
|
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
MotoVaultPro Team',
|
MotoVaultPro Team',
|
||||||
'["userName", "emailSubject", "errorReason"]',
|
'["userName", "errorReason", "guidance"]',
|
||||||
'<!DOCTYPE html>
|
'<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -131,19 +127,13 @@ MotoVaultPro Team',
|
|||||||
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
|
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
|
||||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Subject:</strong> {{emailSubject}}</p>
|
|
||||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Error:</strong> {{errorReason}}</p>
|
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Error:</strong> {{errorReason}}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">Please try again with a clearer image or PDF, or upload the receipt directly through the app.</p>
|
|
||||||
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
|
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
|
||||||
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">Tips for better results:</p>
|
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">What to do next:</p>
|
||||||
<ul style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0; padding-left: 20px;">
|
<p style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0;">{{guidance}}</p>
|
||||||
<li>Use a well-lit, high-contrast photo</li>
|
|
||||||
<li>Ensure text is legible and not cut off</li>
|
|
||||||
<li>PDF receipts work best</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -171,14 +161,15 @@ Your emailed receipt has been processed, but we need your help to complete the r
|
|||||||
Since you have multiple vehicles, please log in to MotoVaultPro and select which vehicle this receipt belongs to.
|
Since you have multiple vehicles, please log in to MotoVaultPro and select which vehicle this receipt belongs to.
|
||||||
|
|
||||||
Record Type: {{recordType}}
|
Record Type: {{recordType}}
|
||||||
Date: {{receiptDate}}
|
Merchant: {{merchantName}}
|
||||||
Amount: ${{amount}}
|
Date: {{date}}
|
||||||
|
Amount: ${{totalAmount}}
|
||||||
|
|
||||||
You can find the pending receipt in your notifications.
|
You can find the pending receipt in your notifications.
|
||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
MotoVaultPro Team',
|
MotoVaultPro Team',
|
||||||
'["userName", "recordType", "receiptDate", "amount"]',
|
'["userName", "recordType", "merchantName", "totalAmount", "date"]',
|
||||||
'<!DOCTYPE html>
|
'<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -208,8 +199,9 @@ MotoVaultPro Team',
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
|
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
|
||||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
|
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
|
||||||
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{receiptDate}}</p>
|
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
|
||||||
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{amount}}</p>
|
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
|
||||||
|
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -445,6 +445,29 @@ export class NotificationsService {
|
|||||||
reason: 'Subscription upgrade',
|
reason: 'Subscription upgrade',
|
||||||
additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!',
|
additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!',
|
||||||
};
|
};
|
||||||
|
case 'receipt_processed':
|
||||||
|
return {
|
||||||
|
...baseVariables,
|
||||||
|
vehicleName: '2024 Toyota Camry',
|
||||||
|
recordType: 'Fuel Log',
|
||||||
|
merchantName: 'Shell Gas Station',
|
||||||
|
totalAmount: '45.50',
|
||||||
|
date: new Date().toLocaleDateString(),
|
||||||
|
};
|
||||||
|
case 'receipt_failed':
|
||||||
|
return {
|
||||||
|
...baseVariables,
|
||||||
|
errorReason: 'Unable to extract receipt data from the attached image.',
|
||||||
|
guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.',
|
||||||
|
};
|
||||||
|
case 'receipt_pending_vehicle':
|
||||||
|
return {
|
||||||
|
...baseVariables,
|
||||||
|
recordType: 'Maintenance Record',
|
||||||
|
merchantName: 'AutoZone',
|
||||||
|
totalAmount: '89.99',
|
||||||
|
date: new Date().toLocaleDateString(),
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return baseVariables;
|
return baseVariables;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user