feat: Maintenance Receipt Upload with OCR Auto-populate (#16) #161

Merged
egullickson merged 11 commits from issue-16-maintenance-receipt-upload-ocr into main 2026-02-13 22:19:45 +00:00
4 changed files with 429 additions and 227 deletions
Showing only changes of commit 8bcac80818 - Show all commits

View File

@@ -1,7 +1,8 @@
/**
* @ai-summary Core processing service for the email-to-record pipeline
* @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';
@@ -17,6 +18,7 @@ import { EmailService } from '../../notifications/domain/email.service';
import { ocrService } from '../../ocr/domain/ocr.service';
import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types';
import { ReceiptClassifier } from './receipt-classifier';
import { EmailIngestionNotificationHandler } from './notification-handler';
import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service';
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
import { FuelType } from '../../fuel-logs/domain/fuel-logs.types';
@@ -56,9 +58,7 @@ export class EmailIngestionService {
private resendClient: ResendInboundClient;
private userProfileRepository: UserProfileRepository;
private vehiclesRepository: VehiclesRepository;
private notificationsRepository: NotificationsRepository;
private templateService: TemplateService;
private emailService: EmailService;
private notificationHandler: EmailIngestionNotificationHandler;
private classifier: ReceiptClassifier;
private fuelLogsService: FuelLogsService;
private maintenanceService: MaintenanceService;
@@ -69,9 +69,12 @@ export class EmailIngestionService {
this.resendClient = new ResendInboundClient();
this.userProfileRepository = new UserProfileRepository(p);
this.vehiclesRepository = new VehiclesRepository(p);
this.notificationsRepository = new NotificationsRepository(p);
this.templateService = new TemplateService();
this.emailService = new EmailService();
const notificationsRepository = new NotificationsRepository(p);
this.notificationHandler = new EmailIngestionNotificationHandler(
notificationsRepository,
new TemplateService(),
new EmailService(),
);
this.classifier = new ReceiptClassifier();
this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p));
this.maintenanceService = new MaintenanceService();
@@ -96,7 +99,7 @@ export class EmailIngestionService {
// 2. Validate sender
const userProfile = await this.validateSender(senderEmail);
if (!userProfile) {
await this.handleUnregisteredSender(emailId, senderEmail, subject);
await this.handleUnregisteredSender(emailId, senderEmail);
return;
}
@@ -112,7 +115,7 @@ export class EmailIngestionService {
// 4. Filter valid attachments
const validAttachments = this.filterAttachments(attachments);
if (validAttachments.length === 0) {
await this.handleNoValidAttachments(emailId, senderEmail, userName, subject);
await this.handleNoValidAttachments(emailId, userId, userName, senderEmail);
return;
}
@@ -129,7 +132,7 @@ export class EmailIngestionService {
userId, validAttachments, emailClassification, emailId
);
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;
}
@@ -147,9 +150,6 @@ export class EmailIngestionService {
processingResult,
});
// 10. Send confirmation email
await this.sendConfirmationEmail(senderEmail, userName, processingResult);
logger.info('Email processing completed successfully', {
emailId,
userId,
@@ -463,7 +463,7 @@ export class EmailIngestionService {
// No vehicles: user must add a vehicle first
if (vehicles.length === 0) {
await this.sendNoVehiclesEmail(userEmail, userName);
await this.notificationHandler.notifyNoVehicles(userId, userName, userEmail);
return {
recordType,
vehicleId: null,
@@ -476,39 +476,38 @@ export class EmailIngestionService {
// Single vehicle: auto-associate and create record
if (vehicles.length === 1) {
const vehicleId = vehicles[0].id;
const vehicle = vehicles[0];
let recordId: string | null = null;
try {
recordId = await this.createRecord(userId, vehicleId, recordType, extractedData);
recordId = await this.createRecord(userId, vehicle.id, recordType, extractedData);
} catch (error) {
logger.error('Failed to create record from email receipt', {
userId,
vehicleId,
vehicleId: vehicle.id,
recordType,
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 message = recordId
? `Your emailed ${recordLabel} receipt has been processed and a ${recordLabel} record was created for your vehicle.`
: `Your emailed ${recordLabel} receipt was processed but the record could not be created automatically. Please add it manually.`;
const vehicleName = vehicle.nickname
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|| 'your vehicle';
await this.notificationsRepository.insertUserNotification({
await this.notificationHandler.notifyReceiptProcessed({
userId,
notificationType: 'email_ingestion',
title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed',
message,
referenceType: recordType,
referenceId: recordId ?? undefined,
vehicleId,
userName,
userEmail,
vehicleName,
recordType,
recordId,
vehicleId: vehicle.id,
extractedData,
});
return {
recordType,
vehicleId,
vehicleId: vehicle.id,
recordId,
documentId: null,
pendingAssociationId: null,
@@ -524,17 +523,15 @@ export class EmailIngestionService {
documentId: null,
});
await this.notificationsRepository.insertUserNotification({
await this.notificationHandler.notifyPendingVehicleSelection({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Pending Vehicle Selection',
message: `Your emailed receipt has been processed. Please select which vehicle this ${recordType === 'fuel_log' ? 'fuel' : 'maintenance'} receipt belongs to.`,
referenceType: 'pending_vehicle_association',
referenceId: pendingAssociation.id,
userName,
userEmail,
recordType,
pendingAssociationId: pendingAssociation.id,
extractedData,
});
await this.sendPendingVehicleEmail(userEmail, userName, recordType, extractedData);
return {
recordType,
vehicleId: null,
@@ -681,13 +678,13 @@ export class EmailIngestionService {
private async handleProcessingError(
emailId: string,
senderEmail: string,
subject: string | null,
_subject: string | null,
error: unknown
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
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 currentRetryCount = queueEntry?.retryCount || 0;
const newRetryCount = currentRetryCount + 1;
@@ -711,11 +708,15 @@ export class EmailIngestionService {
retryCount: newRetryCount,
});
// Send failure email to user
await this.sendFailureEmail(senderEmail, subject, errorMessage).catch(emailErr => {
logger.error('Failed to send failure notification email', {
// Send failure notification (email + in-app if userId available)
await this.notificationHandler.notifyProcessingFailure({
userId: queueEntry?.userId,
userEmail: senderEmail,
errorReason: errorMessage,
}).catch(notifyErr => {
logger.error('Failed to send failure notification', {
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(
emailId: string,
senderEmail: string,
subject: string | null
senderEmail: string
): Promise<void> {
logger.info('Unregistered sender rejected', { emailId, senderEmail });
@@ -732,12 +732,7 @@ export class EmailIngestionService {
errorMessage: 'Sender email is not registered with MotoVaultPro',
});
// Send rejection email
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 => {
await this.notificationHandler.notifyUnregisteredSender(senderEmail).catch(error => {
logger.error('Failed to send unregistered sender notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
@@ -747,9 +742,9 @@ export class EmailIngestionService {
private async handleNoValidAttachments(
emailId: string,
senderEmail: string,
_userName: string,
subject: string | null
userId: string,
userName: string,
userEmail: string
): Promise<void> {
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)',
});
await this.sendFailureEmail(
senderEmail,
subject,
'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.'
).catch(error => {
await this.notificationHandler.notifyNoValidAttachments(userId, userName, userEmail).catch(error => {
logger.error('Failed to send no-attachments notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
@@ -771,9 +762,9 @@ export class EmailIngestionService {
private async handleOcrFailure(
emailId: string,
senderEmail: string,
_userName: string,
subject: string | null,
userId: string,
userName: string,
userEmail: string,
reason: string
): Promise<void> {
logger.info('OCR extraction failed for all attachments', { emailId, reason });
@@ -782,148 +773,11 @@ export class EmailIngestionService {
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', {
emailId,
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),
});
}
}
}

View File

@@ -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),
});
});
}
}
}
}

View File

@@ -31,14 +31,15 @@ Your emailed receipt has been successfully processed.
Vehicle: {{vehicleName}}
Record Type: {{recordType}}
Date: {{receiptDate}}
Amount: ${{amount}}
Merchant: {{merchantName}}
Date: {{date}}
Amount: ${{totalAmount}}
The record has been added to your vehicle history.
Best regards,
MotoVaultPro Team',
'["userName", "vehicleName", "recordType", "receiptDate", "amount"]',
'["userName", "vehicleName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html>
<html>
<head>
@@ -65,8 +66,9 @@ MotoVaultPro Team',
<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>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; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{amount}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</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>
</tr>
</table>
@@ -94,19 +96,13 @@ MotoVaultPro Team',
We were unable to process the receipt you emailed to us.
Subject: {{emailSubject}}
Error: {{errorReason}}
Please try again with a clearer image or PDF, or upload the receipt directly through the app.
Tips for better results:
- Use a well-lit, high-contrast photo
- Ensure text is legible and not cut off
- PDF receipts work best
{{guidance}}
Best regards,
MotoVaultPro Team',
'["userName", "emailSubject", "errorReason"]',
'["userName", "errorReason", "guidance"]',
'<!DOCTYPE html>
<html>
<head>
@@ -131,19 +127,13 @@ MotoVaultPro Team',
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<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>
</td>
</tr>
</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;">
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">Tips for better results:</p>
<ul style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0; padding-left: 20px;">
<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>
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">What to do next:</p>
<p style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0;">{{guidance}}</p>
</div>
</td>
</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.
Record Type: {{recordType}}
Date: {{receiptDate}}
Amount: ${{amount}}
Merchant: {{merchantName}}
Date: {{date}}
Amount: ${{totalAmount}}
You can find the pending receipt in your notifications.
Best regards,
MotoVaultPro Team',
'["userName", "recordType", "receiptDate", "amount"]',
'["userName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html>
<html>
<head>
@@ -208,8 +199,9 @@ MotoVaultPro Team',
<tr>
<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>Date:</strong> {{receiptDate}}</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>Merchant:</strong> {{merchantName}}</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>
</tr>
</table>

View File

@@ -445,6 +445,29 @@ export class NotificationsService {
reason: 'Subscription upgrade',
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:
return baseVariables;
}