From 88a1ef62900cdfbf6e5a21528bf078f3728c0b65 Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Wed, 28 Jan 2026 09:15:22 +0100 Subject: [PATCH] feat: email delivery after payment --- schemas/get-birth-certificate.json | 62 +-- schemas/get-death-certificate.json | 14 +- schemas/get-marriage-certificate.json | 14 +- src/common/utils/date.util.ts | 32 ++ src/common/utils/encryption.util.ts | 55 +++ src/common/utils/index.ts | 2 + .../form-submission-payment.entity.ts | 9 +- ...cryptedFormDataToFormSubmissionPayments.ts | 38 ++ .../1769545016193-RemovePaymentVerified.ts | 27 ++ .../templates/birth-certificate-receipt.hbs | 92 +++-- src/email/templates/birth-certificate.hbs | 48 ++- .../templates/death-certificate-receipt.hbs | 92 +++-- src/email/templates/death-certificate.hbs | 48 ++- .../marriage-certificate-receipt.hbs | 92 +++-- src/email/templates/marriage-certificate.hbs | 48 ++- src/forms/forms.service.ts | 27 +- src/forms/interfaces/form-schema.interface.ts | 17 +- .../abandoned-payment-cleanup.service.ts | 135 +++++++ src/payments/ezpay/ezpay.service.ts | 6 +- .../ezpay/interfaces/ezpay.interface.ts | 19 + src/payments/index.ts | 1 - src/payments/payment-integration.service.ts | 231 ----------- .../payment-reconciliation.service.ts | 50 +-- src/payments/payment-webhook.service.ts | 377 ++++++++++-------- src/payments/payments.module.ts | 11 +- .../implementations/payment.processor.ts | 61 +-- 26 files changed, 944 insertions(+), 664 deletions(-) create mode 100644 src/common/utils/date.util.ts create mode 100644 src/common/utils/encryption.util.ts create mode 100644 src/database/migrations/1769545014343-AddEncryptedFormDataToFormSubmissionPayments.ts create mode 100644 src/database/migrations/1769545016193-RemovePaymentVerified.ts create mode 100644 src/payments/abandoned-payment-cleanup.service.ts delete mode 100644 src/payments/payment-integration.service.ts diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index 6a5af52..c5c2562 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -482,31 +482,39 @@ ] } ], - "processors": [ - { - "type": "payment", - "config": { - "provider": "ezpay", - "department": "oag_registration", - "paymentCode": "{{db:get-birth-certificate:payment_code}}", - "amount": "{{formData.order.numberOfCopies * db:get-birth-certificate:payment_amount}}", - "description": "Birth Certificate Processing Fee (per copy)", - "confirmationEmailTo": ["{{db:get-birth-certificate:admin_email}}"], - "customerEmail": "{{formData.applicant.email}}", - "required": true, - "timing": "after_validation", - "responseData": { - "include": ["order.numberOfCopies"] - } - } - }, - { - "type": "email", - "config": { - "to": "{{db:get-birth-certificate:admin_email}}", - "subject": "New Birth Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", - "template": "birth-certificate" - } - } - ] + "processors": [ + { + "type": "payment", + "config": { + "provider": "ezpay", + "department": "oag_registration", + "paymentCode": "{{db:get-birth-certificate:payment_code}}", + "amount": "{{formData.order.numberOfCopies * db:get-birth-certificate:payment_amount}}", + "description": "Birth Certificate Processing Fee (per copy)", + "required": true, + "timing": "after_validation", + "responseData": { + "include": ["order.numberOfCopies"] + } + } + }, + { + "type": "email", + "config": { + "to": "{{db:get-birth-certificate:admin_email}}", + "subject": "New Birth Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", + "template": "birth-certificate", + "recipientType": "admin" + } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Birth Certificate - Submission Received", + "template": "birth-certificate-receipt", + "recipientType": "user" + } + } + ] } diff --git a/schemas/get-death-certificate.json b/schemas/get-death-certificate.json index 7c2dc85..aa9e150 100644 --- a/schemas/get-death-certificate.json +++ b/schemas/get-death-certificate.json @@ -263,8 +263,6 @@ "department": "oag_registration", "paymentCode": "{{db:get-death-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-death-certificate:payment_amount}}", - "confirmationEmailTo": ["{{db:get-death-certificate:admin_email}}"], - "customerEmail": "{{formData.applicant.email}}", "description": "Death Certificate Processing Fee (per copy)", "required": true, "timing": "after_validation", @@ -278,7 +276,17 @@ "config": { "to": "{{db:get-death-certificate:admin_email}}", "subject": "New Death Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", - "template": "death-certificate" + "template": "death-certificate", + "recipientType": "admin" + } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Death Certificate - Submission Received", + "template": "death-certificate-receipt", + "recipientType": "user" } }, { diff --git a/schemas/get-marriage-certificate.json b/schemas/get-marriage-certificate.json index ee3f1be..56fa36e 100644 --- a/schemas/get-marriage-certificate.json +++ b/schemas/get-marriage-certificate.json @@ -324,8 +324,6 @@ "department": "oag_registration", "paymentCode": "{{db:get-marriage-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-marriage-certificate:payment_amount}}", - "confirmationEmailTo": ["{{db:get-marriage-certificate:admin_email}}"], - "customerEmail": "{{formData.applicant.email}}", "description": "Marriage Certificate Processing Fee (per copy)", "required": true, "timing": "after_validation", @@ -339,7 +337,17 @@ "config": { "to": "{{db:get-marriage-certificate:admin_email}}", "subject": "New Marriage Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", - "template": "marriage-certificate" + "template": "marriage-certificate", + "recipientType": "admin" + } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Marriage Certificate - Submission Received", + "template": "marriage-certificate-receipt", + "recipientType": "user" } } ] diff --git a/src/common/utils/date.util.ts b/src/common/utils/date.util.ts new file mode 100644 index 0000000..a38b589 --- /dev/null +++ b/src/common/utils/date.util.ts @@ -0,0 +1,32 @@ +export function formatDisplayDate( + date: Date | string | undefined | null, +): string { + const dateObj = parseDate(date); + + const datePart = dateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + const timePart = dateObj.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return `${datePart} at ${timePart}`; +} + +function parseDate(date: Date | string | undefined | null): Date { + if (!date) { + return new Date(); + } + + if (typeof date === 'string') { + const parsed = new Date(date); + return isNaN(parsed.getTime()) ? new Date() : parsed; + } + + return date; +} diff --git a/src/common/utils/encryption.util.ts b/src/common/utils/encryption.util.ts new file mode 100644 index 0000000..691bc6d --- /dev/null +++ b/src/common/utils/encryption.util.ts @@ -0,0 +1,55 @@ +import * as crypto from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; + +function getEncryptionKey(): Buffer { + const key = process.env.FORM_DATA_ENCRYPTION_KEY; + return crypto.createHash('sha256').update(key).digest(); +} + +/** + * Encrypt data using AES-256-GCM + * Returns base64 encoded string containing: IV + AuthTag + CipherText + */ +export function encryptFormData(data: Record): string { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const jsonData = JSON.stringify(data); + const encrypted = Buffer.concat([ + cipher.update(jsonData, 'utf8'), + cipher.final(), + ]); + + const authTag = cipher.getAuthTag(); + + const combined = Buffer.concat([iv, authTag, encrypted]); + + return combined.toString('base64'); +} + +/** + * Decrypt data that was encrypted with encryptFormData + * Expects base64 encoded string containing: IV + AuthTag + CipherText + */ +export function decryptFormData(encryptedData: string): Record { + const key = getEncryptionKey(); + const combined = Buffer.from(encryptedData, 'base64'); + + const iv = combined.subarray(0, IV_LENGTH); + const authTag = combined.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = combined.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return JSON.parse(decrypted.toString('utf8')); +} diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 723b010..06a73f3 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1 +1,3 @@ export * from './reference-code.util'; +export * from './encryption.util'; +export * from './date.util'; diff --git a/src/database/entities/form-submission-payment.entity.ts b/src/database/entities/form-submission-payment.entity.ts index 3e0df00..e41f735 100644 --- a/src/database/entities/form-submission-payment.entity.ts +++ b/src/database/entities/form-submission-payment.entity.ts @@ -32,12 +32,15 @@ export class FormSubmissionPayment { @Column({ name: 'payment_completed', default: false }) paymentCompleted: boolean; - @Column({ name: 'payment_verified', default: false }) - paymentVerified: boolean; - @Column({ name: 'notification_sent', default: false }) notificationSent: boolean; + @Column({ name: 'encrypted_form_data', type: 'text', nullable: true }) + encryptedFormData?: string; + + @Column({ name: 'form_data_deleted', default: false }) + formDataDeleted: boolean; + @ManyToOne(() => Payment, (payment) => payment.formSubmissions, { onDelete: 'CASCADE', }) diff --git a/src/database/migrations/1769545014343-AddEncryptedFormDataToFormSubmissionPayments.ts b/src/database/migrations/1769545014343-AddEncryptedFormDataToFormSubmissionPayments.ts new file mode 100644 index 0000000..9158877 --- /dev/null +++ b/src/database/migrations/1769545014343-AddEncryptedFormDataToFormSubmissionPayments.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddEncryptedFormDataToFormSubmissionPayments1769545014343 + implements MigrationInterface +{ + name = 'AddEncryptedFormDataToFormSubmissionPayments1769545014343'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'form_submission_payments', + new TableColumn({ + name: 'encrypted_form_data', + type: 'text', + isNullable: true, + }), + ); + + await queryRunner.addColumn( + 'form_submission_payments', + new TableColumn({ + name: 'form_data_deleted', + type: 'boolean', + default: false, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn( + 'form_submission_payments', + 'form_data_deleted', + ); + await queryRunner.dropColumn( + 'form_submission_payments', + 'encrypted_form_data', + ); + } +} diff --git a/src/database/migrations/1769545016193-RemovePaymentVerified.ts b/src/database/migrations/1769545016193-RemovePaymentVerified.ts new file mode 100644 index 0000000..98164bd --- /dev/null +++ b/src/database/migrations/1769545016193-RemovePaymentVerified.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class RemovePaymentVerified1769545016193 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('form_submission_payments'); + const column = table?.findColumnByName('payment_verified'); + + if (column) { + await queryRunner.dropColumn( + 'form_submission_payments', + 'payment_verified', + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'form_submission_payments', + new TableColumn({ + name: 'payment_verified', + type: 'boolean', + default: false, + isNullable: false, + }), + ); + } +} diff --git a/src/email/templates/birth-certificate-receipt.hbs b/src/email/templates/birth-certificate-receipt.hbs index 28449c8..a1bc054 100644 --- a/src/email/templates/birth-certificate-receipt.hbs +++ b/src/email/templates/birth-certificate-receipt.hbs @@ -2,53 +2,73 @@ -
-
- Government of Barbados -
-
-
-

Birth Certificate Application - Submission Received

+

Birth Certificate Application Received

-
-

Your Submission ID: {{submissionId}}

-

Please save this ID for your records.

+
+

Thank you for submitting your birth certificate application.

+

Your payment has been processed successfully and your application is now being reviewed.

-
-

What happens next

-

Your application for a birth certificate has been received. We will process your request and contact you with payment instructions and collection details for your certified copy.

+
+ Reference Number: {{submissionId}}
+ Please keep this reference number for your records.
-
-

Need help?

-

If you have questions about your application, please contact the Registration Department at the Supreme Court Complex.

+ {{#if paymentInfo}} +
+

Payment Details

+ + {{#if paymentInfo.transactionNumber}} + + + + + {{/if}} + {{#if paymentInfo.amount}} + + + + + {{/if}} +
Transaction Number:{{paymentInfo.transactionNumber}}
Amount Paid:${{paymentInfo.amount}}
-
+ {{/if}} -
-
-

Government of Barbados
- This is an automated confirmation email. Please do not reply.

+

+ What happens next?
+ Your application will be processed by the Registration Department. + Please allow 5-10 business days for processing. +

+ + + -
+
diff --git a/src/email/templates/birth-certificate.hbs b/src/email/templates/birth-certificate.hbs index 278bdf8..0735c53 100644 --- a/src/email/templates/birth-certificate.hbs +++ b/src/email/templates/birth-certificate.hbs @@ -18,6 +18,13 @@ background-color: #f0f8ff; padding: 15px; border-left: 4px solid #003087; } .highlight { background-color: #fff3cd; padding: 10px; border: 1px solid #ffeaa7; border-radius: 4px; margin: 10px 0; } + .staff-box { background-color: #e9ecef; padding: 15px; border-radius: 4px; + margin: 20px 0; border: 1px solid #ced4da; } + .staff-box h3 { color: #495057; margin-top: 0; } + .staff-box table td { border-bottom: 1px solid #ced4da; } + .staff-box td:last-child { min-width: 200px; } + .contact-highlight { background-color: #d4edda; padding: 10px; border: 1px solid + #c3e6cb; border-radius: 4px; margin: 10px 0; } @@ -25,10 +32,18 @@

New Birth Certificate Application

+ +
+ Applicant Phone: {{applicant.phoneNumber}}
+ Applicant Email: {{applicant.email}} +
+ + +
+

Staff Use Only

+ + + + + + + + + + + + + + + +
Due Date:
Reference:
Officer Assigned:
+
+

Applicant Information

diff --git a/src/email/templates/death-certificate-receipt.hbs b/src/email/templates/death-certificate-receipt.hbs index 0a8b020..66637c8 100644 --- a/src/email/templates/death-certificate-receipt.hbs +++ b/src/email/templates/death-certificate-receipt.hbs @@ -2,53 +2,73 @@ -
-
- Government of Barbados -
-
-
-

Death Certificate Application - Submission Received

+

Death Certificate Application Received

-
-

Your Submission ID: {{submissionId}}

-

Please save this ID for your records.

+
+

Thank you for submitting your death certificate application.

+

Your payment has been processed successfully and your application is now being reviewed.

-
-

What happens next

-

Your application for a death certificate has been received. We will process your request and contact you with payment instructions and collection details for your certified copy.

+
+ Reference Number: {{submissionId}}
+ Please keep this reference number for your records.
-
-

Need help?

-

If you have questions about your application, please contact the Registration Department at the Supreme Court Complex.

+ {{#if paymentInfo}} +
+

Payment Details

+ + {{#if paymentInfo.transactionNumber}} + + + + + {{/if}} + {{#if paymentInfo.amount}} + + + + + {{/if}} +
Transaction Number:{{paymentInfo.transactionNumber}}
Amount Paid:${{paymentInfo.amount}}
-
+ {{/if}} -
-
-

Government of Barbados
- This is an automated confirmation email. Please do not reply.

+

+ What happens next?
+ Your application will be processed by the Registration Department. + Please allow 5-10 business days for processing. +

+ + + -
+
diff --git a/src/email/templates/death-certificate.hbs b/src/email/templates/death-certificate.hbs index 69f0f50..e5a8de1 100644 --- a/src/email/templates/death-certificate.hbs +++ b/src/email/templates/death-certificate.hbs @@ -18,6 +18,13 @@ background-color: #f0f8ff; padding: 15px; border-left: 4px solid #003087; } .highlight { background-color: #fff3cd; padding: 10px; border: 1px solid #ffeaa7; border-radius: 4px; margin: 10px 0; } + .staff-box { background-color: #e9ecef; padding: 15px; border-radius: 4px; + margin: 20px 0; border: 1px solid #ced4da; } + .staff-box h3 { color: #495057; margin-top: 0; } + .staff-box table td { border-bottom: 1px solid #ced4da; } + .staff-box td:last-child { min-width: 200px; } + .contact-highlight { background-color: #d4edda; padding: 10px; border: 1px solid + #c3e6cb; border-radius: 4px; margin: 10px 0; } @@ -25,14 +32,49 @@

New Death Certificate Application

+ +
+ Applicant Phone: {{applicant.telephoneNumber}}
+ Applicant Email: {{applicant.email}} +
+ + +
+

Staff Use Only

+ + + + + + + + + + + + + + + +
Due Date:
Reference:
Officer Assigned:
+
+

Applicant Information

diff --git a/src/email/templates/marriage-certificate-receipt.hbs b/src/email/templates/marriage-certificate-receipt.hbs index 345cb75..7cb1aa4 100644 --- a/src/email/templates/marriage-certificate-receipt.hbs +++ b/src/email/templates/marriage-certificate-receipt.hbs @@ -2,53 +2,73 @@ -
-
- Government of Barbados -
-
-
-

Marriage Certificate Application - Submission Received

+

Marriage Certificate Application Received

-
-

Your Submission ID: {{submissionId}}

-

Please save this ID for your records.

+
+

Thank you for submitting your marriage certificate application.

+

Your payment has been processed successfully and your application is now being reviewed.

-
-

What happens next

-

Your application for a marriage certificate has been received. We will process your request and contact you with payment instructions and collection details for your certified copy.

+
+ Reference Number: {{submissionId}}
+ Please keep this reference number for your records.
-
-

Need help?

-

If you have questions about your application, please contact the Registration Department at the Supreme Court Complex.

+ {{#if paymentInfo}} +
+

Payment Details

+ + {{#if paymentInfo.transactionNumber}} + + + + + {{/if}} + {{#if paymentInfo.amount}} + + + + + {{/if}} +
Transaction Number:{{paymentInfo.transactionNumber}}
Amount Paid:${{paymentInfo.amount}}
-
+ {{/if}} -
-
-

Government of Barbados
- This is an automated confirmation email. Please do not reply.

+

+ What happens next?
+ Your application will be processed by the Registration Department. + Please allow 5-10 business days for processing. +

+ + + -
+
diff --git a/src/email/templates/marriage-certificate.hbs b/src/email/templates/marriage-certificate.hbs index ea1af16..ecb6b4a 100644 --- a/src/email/templates/marriage-certificate.hbs +++ b/src/email/templates/marriage-certificate.hbs @@ -18,6 +18,13 @@ background-color: #f0f8ff; padding: 15px; border-left: 4px solid #003087; } .highlight { background-color: #fff3cd; padding: 10px; border: 1px solid #ffeaa7; border-radius: 4px; margin: 10px 0; } + .staff-box { background-color: #e9ecef; padding: 15px; border-radius: 4px; + margin: 20px 0; border: 1px solid #ced4da; } + .staff-box h3 { color: #495057; margin-top: 0; } + .staff-box table td { border-bottom: 1px solid #ced4da; } + .staff-box td:last-child { min-width: 200px; } + .contact-highlight { background-color: #d4edda; padding: 10px; border: 1px solid + #c3e6cb; border-radius: 4px; margin: 10px 0; } @@ -25,10 +32,18 @@

New Marriage Certificate Application

+ +
+ Applicant Phone: {{applicant.phoneNumber}}
+ Applicant Email: {{applicant.email}} +
+ + +
+

Staff Use Only

+ + + + + + + + + + + + + + + +
Due Date:
Reference:
Officer Assigned:
+
+

Applicant Information

diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index 5e5075d..cc34599 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -159,12 +159,8 @@ export class FormsService { (processor: any) => processor.type === 'payment', ); - // Separate payment and non-payment processors - const nonPaymentProcessors = formSchema.processors.filter( - (processor: any) => processor.type !== 'payment', - ); - - // Execute payment processor first + // Execute payment processor only (form data is encrypted and stored here) + // No other processors (like email) are executed at submission time const paymentResult = await this.processorPipeline.executeProcessor( paymentProcessor, { @@ -183,21 +179,9 @@ export class FormsService { }; } - // Execute non-payment processors (like sending admin notification emails) - if (nonPaymentProcessors.length > 0) { - await this.processorPipeline.execute(nonPaymentProcessors, { - formId, - submissionId, - data: { - ...data, - paymentInfo: { - paymentId: paymentResult.paymentId, - referenceNumber: paymentResult.referenceNumber, - amount: paymentProcessor.config.amount, - }, - }, - }); - } + this.logger.log( + `Payment form submitted - awaiting payment confirmation for ${formId}:${submissionId}`, + ); // Return payment response const response = new FormSubmissionResponseDto( @@ -221,7 +205,6 @@ export class FormsService { data: response, }; } catch (error) { - console.log(error); this.logger.error( `Payment processing failed for ${formId}:${submissionId}`, error, diff --git a/src/forms/interfaces/form-schema.interface.ts b/src/forms/interfaces/form-schema.interface.ts index 328c60a..436ab49 100644 --- a/src/forms/interfaces/form-schema.interface.ts +++ b/src/forms/interfaces/form-schema.interface.ts @@ -67,8 +67,6 @@ export interface PaymentProcessorConfig extends ProcessorConfig { required?: boolean; // Whether payment is mandatory for form submission timing?: 'immediate' | 'after_validation'; // When to create payment responseData?: ResponseDataConfig; // Additional data to include in response - confirmationEmailTo?: string[]; // Email addresses to send payment confirmation to (supports expressions) - customerEmail?: string; // Customer email expression to send payment confirmation to (supports expressions) }; } @@ -88,3 +86,18 @@ export interface OpenCRVSProcessorConfig extends ProcessorConfig { parishName?: string; // Location name (resolved to ID at runtime) }; } + +export type EmailRecipientType = 'admin' | 'user'; + +export interface EmailProcessorConfig extends ProcessorConfig { + type: 'email'; + config: { + to: string | string[]; // Recipient email(s) - supports expressions like "{{formData.applicant.email}}" + from?: string; // Optional sender email + subject: string; // Email subject - supports expressions + template?: string; // Handlebars template name + html?: string; // Raw HTML content + text?: string; // Plain text content + recipientType?: EmailRecipientType; // 'admin' or 'user' - helps identify email purpose + }; +} diff --git a/src/payments/abandoned-payment-cleanup.service.ts b/src/payments/abandoned-payment-cleanup.service.ts new file mode 100644 index 0000000..140058c --- /dev/null +++ b/src/payments/abandoned-payment-cleanup.service.ts @@ -0,0 +1,135 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { + Payment, + PaymentStatus, + FormSubmissionPayment, +} from '../database/entities'; + +@Injectable() +export class AbandonedPaymentCleanupService { + private readonly logger = new Logger(AbandonedPaymentCleanupService.name); + private readonly abandonedPaymentTTLHours: number; + + constructor( + @InjectRepository(Payment) + private paymentRepository: Repository, + @InjectRepository(FormSubmissionPayment) + private formSubmissionPaymentRepository: Repository, + private configService: ConfigService, + ) { + // Default TTL: 72 hours (3 days) for abandoned payments + this.abandonedPaymentTTLHours = this.configService.get( + 'payments.abandonedPaymentTTLHours', + 72, + ); + } + + /** + * Scheduled job to clean up abandoned payment submissions + * Runs daily at 2:00 AM + * + * Abandonment criteria: + * - Payment status is PENDING or INITIATED + * - Payment was created more than TTL hours ago + * - Form data has not been deleted yet + */ + @Cron(CronExpression.EVERY_DAY_AT_2AM) + async cleanupAbandonedPayments(): Promise { + this.logger.log('Starting abandoned payment cleanup job'); + + try { + const cutoffDate = new Date(); + cutoffDate.setHours( + cutoffDate.getHours() - this.abandonedPaymentTTLHours, + ); + + // Find abandoned payments + const abandonedPayments = await this.paymentRepository.find({ + where: [ + { + status: PaymentStatus.PENDING, + createdAt: LessThan(cutoffDate), + }, + { + status: PaymentStatus.INITIATED, + createdAt: LessThan(cutoffDate), + }, + ], + }); + + if (abandonedPayments.length === 0) { + this.logger.log('No abandoned payments found'); + return; + } + + this.logger.log( + `Found ${abandonedPayments.length} abandoned payments to clean up`, + ); + + let cleanedCount = 0; + let errorCount = 0; + + for (const payment of abandonedPayments) { + try { + await this.cleanupPayment(payment); + cleanedCount++; + } catch (error) { + this.logger.error( + `Failed to cleanup payment ${payment.id}: ${error.message}`, + ); + errorCount++; + } + } + + this.logger.log( + `Abandoned payment cleanup completed: ${cleanedCount} cleaned, ${errorCount} errors`, + ); + } catch (error) { + this.logger.error('Abandoned payment cleanup job failed', { + error: error.message, + stack: error.stack, + }); + } + } + + /** + * Clean up a single abandoned payment + * - Securely delete encrypted form data + * - Mark payment as cancelled + */ + private async cleanupPayment(payment: Payment): Promise { + // Find associated form submission payment + const formSubmissionPayment = + await this.formSubmissionPaymentRepository.findOne({ + where: { paymentId: payment.id }, + }); + + if (formSubmissionPayment && !formSubmissionPayment.formDataDeleted) { + // Securely delete encrypted form data + await this.formSubmissionPaymentRepository.update( + { id: formSubmissionPayment.id }, + { + encryptedFormData: null, + formDataDeleted: true, + }, + ); + + this.logger.log( + `Deleted encrypted form data for abandoned payment ${payment.id}`, + ); + } + + // Update payment status to cancelled + await this.paymentRepository.update(payment.id, { + status: PaymentStatus.CANCELLED, + }); + + this.logger.log( + `Marked payment ${payment.id} as cancelled (abandoned after ${this.abandonedPaymentTTLHours} hours)`, + ); + } +} diff --git a/src/payments/ezpay/ezpay.service.ts b/src/payments/ezpay/ezpay.service.ts index 923b855..620431e 100644 --- a/src/payments/ezpay/ezpay.service.ts +++ b/src/payments/ezpay/ezpay.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; import { DepartmentMappingService } from '../department-mapping.service'; import { CreatePaymentParams, @@ -92,9 +93,12 @@ export class EZPayService { /** * Generate a unique process ID (20 characters) + * Uses cryptographically secure random bytes */ generateProcessId(): string { - return Date.now().toString() + Math.random().toString(36).substring(2, 12); + const timestamp = Date.now().toString(36); // Base36 timestamp (~8 chars) + const randomPart = crypto.randomBytes(6).toString('hex'); // 12 hex chars + return (timestamp + randomPart).substring(0, 20); } /** diff --git a/src/payments/ezpay/interfaces/ezpay.interface.ts b/src/payments/ezpay/interfaces/ezpay.interface.ts index 171619f..3d638d5 100644 --- a/src/payments/ezpay/interfaces/ezpay.interface.ts +++ b/src/payments/ezpay/interfaces/ezpay.interface.ts @@ -155,3 +155,22 @@ export interface QueryTransactionsResult { data?: EZPayTransaction[]; error?: string; } + +/** + * Map EZPay transaction status to internal PaymentStatus + * Centralized to avoid duplication across services + */ +export function mapEZPayStatusToPaymentStatus( + ezpayStatus: EZPayTransactionStatus | string, +): 'pending' | 'initiated' | 'success' | 'failed' { + switch (ezpayStatus) { + case 'Success': + return 'success'; + case 'Failed': + return 'failed'; + case 'Initiated': + return 'initiated'; + default: + return 'pending'; + } +} diff --git a/src/payments/index.ts b/src/payments/index.ts index 077759f..ba5914f 100644 --- a/src/payments/index.ts +++ b/src/payments/index.ts @@ -1,4 +1,3 @@ export * from './payments.module'; -export * from './payment-integration.service'; export * from './department-mapping.service'; export * from './ezpay'; diff --git a/src/payments/payment-integration.service.ts b/src/payments/payment-integration.service.ts deleted file mode 100644 index b2635c7..0000000 --- a/src/payments/payment-integration.service.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { EZPayService } from './ezpay/ezpay.service'; -import { DepartmentMappingService } from './department-mapping.service'; -import { EZPayCartItem, CreatePaymentResult } from './ezpay/interfaces'; - -export interface PaymentIntegrationOptions { - formId: string; - submissionId: string; - department: string; // Required department for API key selection - paymentCode: string; - amount: number; - description: string; - customerEmail: string; - customerName: string; - reference?: string; -} - -@Injectable() -export class PaymentIntegrationService { - private readonly logger = new Logger(PaymentIntegrationService.name); - - constructor( - private readonly ezpayService: EZPayService, - private readonly departmentMappingService: DepartmentMappingService, - ) {} - - /** - * Create a payment for a form submission - */ - async createPaymentForSubmission( - options: PaymentIntegrationOptions, - ): Promise { - const { - formId, - submissionId, - department, - paymentCode, - amount, - description, - customerEmail, - customerName, - reference, - } = options; - - this.logger.log(`Creating payment for form submission`, { - formId, - submissionId, - department, - amount, - customerEmail, - }); - - // Get the correct API key for the department - const apiKey = department - ? this.departmentMappingService.getApiKeyForDepartment(department) - : this.departmentMappingService.getApiKeyForDepartment('default'); - - // Create reference number with department prefix - department is required - if (!department) { - throw new Error('Department is required for payment creation'); - } - - const referenceNumber = - reference || `${department.toUpperCase()}-${formId}-${submissionId}`; - - // Create cart item for the form payment - const cartItems: EZPayCartItem[] = [ - { - code: paymentCode, - amount, - details: description, - reference: referenceNumber, - }, - ]; - - try { - const result = await this.ezpayService.createPayment( - { - cartItems, - customerEmail, - customerName, - referenceNumber, - processId: this.ezpayService.generateProcessId(), - allowCredit: true, - allowDebit: true, - allowPayce: true, - }, - apiKey, - ); - - if (result.success) { - this.logger.log(`Payment created for submission ${submissionId}`, { - token: result.token, - referenceNumber: result.referenceNumber, - }); - } else { - // TypeScript doesn't narrow the type properly, so we assert the failure type - const failureResult = result as { - success: false; - error: string; - code?: string; - }; - this.logger.error( - `Payment creation failed for submission ${submissionId}`, - { - error: failureResult.error, - code: failureResult.code, - }, - ); - } - - return result; - } catch (error) { - this.logger.error( - `Failed to create payment for submission ${submissionId}`, - error, - ); - throw error; - } - } - - /** - * Verify payment for a form submission - */ - async verifyPaymentForSubmission( - transactionNumber?: string, - reference?: string, - ) { - this.logger.log('Verifying payment for form submission', { - transactionNumber, - reference, - }); - - try { - // EZPayService automatically determines the correct API key from the reference - const result = await this.ezpayService.verifyPayment({ - transactionNumber, - reference, - }); - - if (result.success && result.data) { - this.logger.log('Payment verification successful', { - status: result.data._status, - amount: result.data._amount, - transactionNumber: result.data._transaction_number, - }); - } - - return result; - } catch (error) { - this.logger.error('Failed to verify payment for form submission', error); - throw error; - } - } - - /** - * Process payment callback for form submissions - */ - async processPaymentCallback(callbackData: { - _reference: string; - _status: string; - _transaction_number: string; - _amount: string; - }) { - this.logger.log('Processing payment callback for form submission', { - reference: callbackData._reference, - status: callbackData._status, - transactionNumber: callbackData._transaction_number, - }); - - try { - // Extract form ID and submission ID from reference - // Format: DEPARTMENT-formId-submissionId (e.g., EDUCATION-form123-sub456) - const referenceParts = callbackData._reference.split('-'); - - if (referenceParts.length >= 3) { - const department = referenceParts[0].toLowerCase(); - const formId = referenceParts[1]; - const submissionId = referenceParts[2]; - - this.logger.log('Processing callback for department-based reference', { - department, - formId, - submissionId, - reference: callbackData._reference, - }); - - this.logger.log('Extracted form info from payment reference', { - formId, - submissionId, - department, - reference: callbackData._reference, - }); - - // Here you would typically: - // 1. Update the form submission record with payment status - // 2. Send confirmation emails to the user - // 3. Trigger any post-payment processing workflows - // 4. Update any related business logic - - if (callbackData._status === 'Success') { - this.logger.log( - `Payment successful for form ${formId}, submission ${submissionId}${ - department ? ` (department: ${department})` : '' - }`, - ); - // Handle successful payment - } else if (callbackData._status === 'Failed') { - this.logger.warn( - `Payment failed for form ${formId}, submission ${submissionId}${ - department ? ` (department: ${department})` : '' - }`, - ); - // Handle failed payment - } - } else { - this.logger.warn('Unable to extract form info from payment reference', { - reference: callbackData._reference, - }); - } - - return { - status: 'processed', - message: 'Payment callback processed successfully', - }; - } catch (error) { - this.logger.error('Failed to process payment callback', error); - throw error; - } - } -} diff --git a/src/payments/payment-reconciliation.service.ts b/src/payments/payment-reconciliation.service.ts index d604fc7..589498d 100644 --- a/src/payments/payment-reconciliation.service.ts +++ b/src/payments/payment-reconciliation.service.ts @@ -1,12 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { Repository } from 'typeorm'; import { Payment, PaymentStatus } from '../database/entities'; import { EZPayService } from './ezpay/ezpay.service'; import { PaymentWebhookService } from './payment-webhook.service'; import { DepartmentMappingService } from './department-mapping.service'; -import { EZPayTransaction } from './ezpay/interfaces'; +import { + EZPayTransaction, + mapEZPayStatusToPaymentStatus, +} from './ezpay/interfaces'; @Injectable() export class PaymentReconciliationService { @@ -194,7 +197,9 @@ export class PaymentReconciliationService { // Check if status needs to be updated const currentStatus = payment.status; - const newStatus = this.mapEZPayStatusToPaymentStatus(ezpayStatus); + const newStatus = mapEZPayStatusToPaymentStatus( + ezpayStatus, + ) as PaymentStatus; // Only process if status is different and payment is not already in final state if (currentStatus === newStatus) { @@ -247,22 +252,6 @@ export class PaymentReconciliationService { return transaction.Cart?.[0]?.[0]?.reference || null; } - /** - * Map EZPay status to internal payment status - */ - private mapEZPayStatusToPaymentStatus(ezpayStatus: string): PaymentStatus { - switch (ezpayStatus) { - case 'Success': - return PaymentStatus.SUCCESS; - case 'Failed': - return PaymentStatus.FAILED; - case 'Initiated': - return PaymentStatus.INITIATED; - default: - return PaymentStatus.PENDING; - } - } - /** * Get today's date range in YYYY-MM-DD HH:mm format */ @@ -278,29 +267,6 @@ export class PaymentReconciliationService { }; } - /** - * Manual trigger for reconciliation (can be called via API endpoint) - */ - async triggerReconciliation(): Promise<{ - success: boolean; - message: string; - }> { - if (this.isRunning) { - return { - success: false, - message: 'Reconciliation job is already running', - }; - } - - // Run in background - this.reconcileTransactions(); - - return { - success: true, - message: 'Reconciliation job triggered', - }; - } - /** * Manual trigger for department-specific reconciliation */ diff --git a/src/payments/payment-webhook.service.ts b/src/payments/payment-webhook.service.ts index 22a8da1..b19e63b 100644 --- a/src/payments/payment-webhook.service.ts +++ b/src/payments/payment-webhook.service.ts @@ -15,8 +15,11 @@ import { VerifyPaymentParams, VerifyPaymentResult, EZPayVerifyResponse, + mapEZPayStatusToPaymentStatus, } from './ezpay/interfaces'; -import { EmailService } from '../email/email.service'; +import { FormUtilsService } from '../forms/form-utils.service'; +import { ProcessorPipelineService } from '../processors/processor-pipeline.service'; +import { decryptFormData, formatDisplayDate } from '../common/utils'; @Injectable() export class PaymentWebhookService { @@ -30,7 +33,8 @@ export class PaymentWebhookService { @InjectRepository(FormSubmissionPayment) private formSubmissionPaymentRepository: Repository, private ezpayService: EZPayService, - private emailService: EmailService, + private formUtilsService: FormUtilsService, + private processorPipelineService: ProcessorPipelineService, ) {} /** @@ -173,10 +177,10 @@ export class PaymentWebhookService { // Use verified data as source of truth const finalCallbackData: EZPayCallbackDto = { _reference: callbackData._reference, - _status: verifiedData._status as any, + _status: verifiedData._status, _transaction_number: verifiedData._transaction_number, _ezpay_account: verifiedData._ezpay_account, - _processor: verifiedData._processor as any, + _processor: verifiedData._processor, _datesettled: verifiedData._datesettled, _amount: verifiedData._amount, _pcode: callbackData._pcode, @@ -187,7 +191,9 @@ export class PaymentWebhookService { await this.upsertTransaction(payment.id, finalCallbackData); // Update payment status using verified status - const newPaymentStatus = this.mapEZPayStatusToPaymentStatus(finalStatus); + const newPaymentStatus = mapEZPayStatusToPaymentStatus( + finalStatus, + ) as PaymentStatus; await this.updatePaymentStatus(payment.id, newPaymentStatus); // Update form submission payment status using verified status @@ -272,13 +278,62 @@ export class PaymentWebhookService { } /** - * Update payment status + * Valid payment status transitions + */ + private readonly validTransitions: Record = { + [PaymentStatus.PENDING]: [ + PaymentStatus.INITIATED, + PaymentStatus.SUCCESS, + PaymentStatus.FAILED, + PaymentStatus.CANCELLED, + ], + [PaymentStatus.INITIATED]: [ + PaymentStatus.SUCCESS, + PaymentStatus.FAILED, + PaymentStatus.CANCELLED, + ], + [PaymentStatus.SUCCESS]: [PaymentStatus.REFUNDED], + [PaymentStatus.FAILED]: [PaymentStatus.PENDING], // Allow retry + [PaymentStatus.CANCELLED]: [], + [PaymentStatus.REFUNDED]: [], + }; + + /** + * Check if a status transition is valid + */ + private isValidTransition( + currentStatus: PaymentStatus, + newStatus: PaymentStatus, + ): boolean { + if (currentStatus === newStatus) { + return true; // No change is always valid + } + return this.validTransitions[currentStatus]?.includes(newStatus) ?? false; + } + + /** + * Update payment status with state machine validation */ private async updatePaymentStatus( paymentId: string, - status: PaymentStatus, + newStatus: PaymentStatus, ): Promise { - await this.paymentRepository.update(paymentId, { status }); + const payment = await this.paymentRepository.findOne({ + where: { id: paymentId }, + }); + + if (!payment) { + throw new Error(`Payment not found: ${paymentId}`); + } + + if (!this.isValidTransition(payment.status, newStatus)) { + this.logger.warn( + `Invalid status transition for payment ${paymentId}: ${payment.status} -> ${newStatus}`, + ); + return; + } + + await this.paymentRepository.update(paymentId, { status: newStatus }); } /** @@ -292,10 +347,8 @@ export class PaymentWebhookService { if (ezpayStatus === 'Success') { updates.paymentCompleted = true; - updates.paymentVerified = true; } else if (ezpayStatus === 'Failed') { updates.paymentCompleted = false; - updates.paymentVerified = false; } if (Object.keys(updates).length > 0) { @@ -308,101 +361,206 @@ export class PaymentWebhookService { /** * Process successful payment workflows + * + * Workflow: + * 1. Check idempotency (skip if already processed) + * 2. Retrieve and decrypt form data + * 3. Get email processors from form schema (separated by recipientType) + * 4. Execute admin emails with full form data + payment info + * 5. Execute user emails with payment confirmation only + * 6. Delete encrypted form data after successful email delivery */ private async processSuccessfulPayment( payment: Payment, callbackData: EZPayCallbackDto, ): Promise { + const formId = payment.metadata?.formId || ''; + const submissionId = payment.metadata?.submissionId || ''; + const formName = payment.metadata?.formName || 'Form Submission'; + this.logger.log( `Processing successful payment workflows for ${payment.id}`, { + formId, + submissionId, amount: callbackData._amount, transactionNumber: callbackData._transaction_number, }, ); - // Send payment confirmation email if confirmationEmailTo is configured - await this.sendPaymentConfirmationEmail(payment, callbackData); + // Get the form submission payment record + const formSubmissionPayment = + await this.formSubmissionPaymentRepository.findOne({ + where: { paymentId: payment.id }, + }); + + if (!formSubmissionPayment) { + this.logger.error( + `FormSubmissionPayment not found for payment ${payment.id}`, + ); + return; + } + + // Idempotency check: skip if already processed + if (formSubmissionPayment.notificationSent) { + this.logger.log( + `Payment ${payment.id} already processed (notification already sent), skipping`, + ); + return; + } + + if (formSubmissionPayment.formDataDeleted) { + this.logger.warn( + `Form data already deleted for payment ${payment.id}, cannot send emails`, + ); + return; + } + + let formData: Record = {}; + if (formSubmissionPayment.encryptedFormData) { + try { + formData = decryptFormData(formSubmissionPayment.encryptedFormData); + } catch (error) { + this.logger.error( + `Failed to decrypt form data for payment ${payment.id}`, + { error: error.message }, + ); + return; + } + } + + // Get form schema with email processors + let emailProcessors: any[] = []; + try { + const formSchema = await this.formUtilsService.getSchemaWithSecrets( + formId, + formData, + ); + emailProcessors = formSchema.processors.filter( + (processor) => processor.type === 'email', + ); + } catch (error) { + this.logger.error(`Failed to get form schema for ${formId}`, { + error: error.message, + }); + // Don't delete form data - allow retry when schema is available + return; + } - // Mark notification as sent - await this.formSubmissionPaymentRepository.update( - { paymentId: payment.id }, - { notificationSent: true }, + // Separate email processors by recipientType + const adminEmailProcessors = emailProcessors.filter( + (p) => p.config.recipientType === 'admin', + ); + const userEmailProcessors = emailProcessors.filter( + (p) => p.config.recipientType === 'user', + ); + // Processors without recipientType default to admin behavior (full form data) + const untaggedEmailProcessors = emailProcessors.filter( + (p) => !p.config.recipientType, ); - } - /** - * Send payment confirmation emails to admin and customer - */ - private async sendPaymentConfirmationEmail( - payment: Payment, - callbackData: EZPayCallbackDto, - ): Promise { - const adminEmails: string[] = payment.metadata?.confirmationEmailTo || []; - const customerEmail = payment.metadata?.configCustomerEmail; - const formName = payment.metadata?.formName || 'Form Submission'; - const formId = payment.metadata?.formId || ''; - const submissionId = payment.metadata?.submissionId || ''; + // Payment info shared by all emails + const processedAt = formatDisplayDate(callbackData._datesettled); - const emailData = { - formName, - formId, - submissionId, + const paymentInfo = { + paymentId: payment.id, referenceNumber: payment.referenceNumber, transactionNumber: callbackData._transaction_number, amount: callbackData._amount, processor: callbackData._processor, customerName: payment.customerName, customerEmail: payment.customerEmail, - description: payment.description, + processedAt, }; - // Send admin emails (using admin template) - const uniqueAdminEmails = [...new Set(adminEmails.filter(Boolean))]; - for (const adminEmail of uniqueAdminEmails) { - try { - await this.emailService.sendEmail({ - to: adminEmail, - subject: `${formName} payment (reference number: ${submissionId})`, - template: 'payment-confirmation', - data: emailData, - }); + // Submission timestamp (when form was originally submitted) + const submittedAt = formatDisplayDate(formSubmissionPayment.createdAt); + + // Context for admin emails: includes full form data + payment info + const adminContext = { + formId, + submissionId, + data: { + ...formData, + formName, + submittedAt, + paymentInfo, + }, + }; + + // Context for user emails: only payment confirmation, no PII form data + const userContext = { + formId, + submissionId, + data: { + formName, + submittedAt, + paymentInfo, + }, + }; + let emailsSentSuccessfully = true; + + const adminProcessors = [ + ...adminEmailProcessors, + ...untaggedEmailProcessors, + ]; + if (adminProcessors.length > 0) { + try { + await this.processorPipelineService.execute( + adminProcessors, + adminContext, + ); this.logger.log( - `Admin payment confirmation email sent to ${adminEmail} for payment ${payment.id}`, + `Admin email processors (${adminProcessors.length}) executed successfully for payment ${payment.id}`, ); } catch (error) { this.logger.error( - `Failed to send admin payment confirmation email to ${adminEmail} for payment ${payment.id}`, + `Failed to execute admin email processors for payment ${payment.id}`, { error: error.message }, ); + emailsSentSuccessfully = false; } } - // Send customer email (using customer-friendly template) - if (customerEmail) { + if (userEmailProcessors.length > 0) { try { - await this.emailService.sendEmail({ - to: customerEmail, - subject: `Thank you for your request`, - template: 'payment-confirmation-customer', - data: emailData, - }); - + await this.processorPipelineService.execute( + userEmailProcessors, + userContext, + ); this.logger.log( - `Customer payment confirmation email sent to ${customerEmail} for payment ${payment.id}`, + `User email processors (${userEmailProcessors.length}) executed successfully for payment ${payment.id}`, ); } catch (error) { this.logger.error( - `Failed to send customer payment confirmation email to ${customerEmail} for payment ${payment.id}`, + `Failed to execute user email processors for payment ${payment.id}`, { error: error.message }, ); + emailsSentSuccessfully = false; } } - if (uniqueAdminEmails.length === 0 && !customerEmail) { + if (emailProcessors.length === 0) { this.logger.log( - `No email recipients configured for payment ${payment.id}, skipping email`, + `No email processors configured for form ${formId}, skipping emails`, + ); + } + + // Only delete form data and mark as sent if emails were successful + if (emailsSentSuccessfully) { + // Securely delete encrypted form data + await this.formSubmissionPaymentRepository.update( + { id: formSubmissionPayment.id }, + { + encryptedFormData: null, + formDataDeleted: true, + notificationSent: true, + }, + ); + + this.logger.log( + `Form data securely deleted for payment ${payment.id} after successful email delivery`, ); } } @@ -425,22 +583,6 @@ export class PaymentWebhookService { // - Send alternative payment instructions } - /** - * Map EZPay status to internal payment status - */ - private mapEZPayStatusToPaymentStatus(ezpayStatus: string): PaymentStatus { - switch (ezpayStatus) { - case 'Success': - return PaymentStatus.SUCCESS; - case 'Failed': - return PaymentStatus.FAILED; - case 'Initiated': - return PaymentStatus.INITIATED; - default: - return PaymentStatus.PENDING; - } - } - /** * Map EZPay processor to internal enum */ @@ -588,9 +730,9 @@ export class PaymentWebhookService { // Check if status needs to be updated const currentStatus = payment.status; - const verifiedStatus = this.mapEZPayStatusToPaymentStatus( + const verifiedStatus = mapEZPayStatusToPaymentStatus( paymentData._status, - ); + ) as PaymentStatus; if (currentStatus !== verifiedStatus) { // Update payment status @@ -605,10 +747,10 @@ export class PaymentWebhookService { // Create/update transaction record const callbackData: EZPayCallbackDto = { _reference: serializedDetails?.reference || reference || '', - _status: paymentData._status as any, + _status: paymentData._status, _transaction_number: paymentData._transaction_number, _ezpay_account: paymentData._ezpay_account, - _processor: paymentData._processor as any, + _processor: paymentData._processor, _datesettled: paymentData._datesettled, _amount: paymentData._amount, _pcode: '', @@ -661,81 +803,4 @@ export class PaymentWebhookService { }; } } - - /** - * Verify payment by transaction number - */ - async verifyPaymentByTransactionNumber(transactionNumber: string): Promise<{ - success: boolean; - verificationResult?: EZPayVerifyResponse; - message: string; - }> { - try { - this.logger.log( - `Verifying payment by transaction number: ${transactionNumber}`, - ); - - const verificationResult = await this.verifyPaymentStatus({ - transactionNumber, - }); - - if (!verificationResult.success || !verificationResult.data) { - return { - success: false, - message: `EZPay verification failed: ${verificationResult.error}`, - }; - } - - return { - success: true, - verificationResult: verificationResult.data, - message: 'Payment verification successful', - }; - } catch (error) { - this.logger.error('Payment verification by transaction number failed', { - error: error.message, - transactionNumber, - }); - - return { - success: false, - message: `Verification failed: ${error.message}`, - }; - } - } - - /** - * Get payment and transaction status for a form submission - */ - async getPaymentStatusForSubmission( - formId: string, - submissionId: string, - ): Promise<{ - hasPayment: boolean; - payment?: Payment; - transactions?: PaymentTransaction[]; - formSubmissionPayment?: FormSubmissionPayment; - }> { - const formSubmissionPayment = - await this.formSubmissionPaymentRepository.findOne({ - where: { formId, submissionId }, - relations: ['payment'], - }); - - if (!formSubmissionPayment) { - return { hasPayment: false }; - } - - const transactions = await this.transactionRepository.find({ - where: { paymentId: formSubmissionPayment.payment.id }, - order: { createdAt: 'DESC' }, - }); - - return { - hasPayment: true, - payment: formSubmissionPayment.payment, - transactions, - formSubmissionPayment, - }; - } } diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index a5853a6..48ac3b6 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -1,10 +1,10 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; -import { PaymentIntegrationService } from './payment-integration.service'; import { PaymentWebhookService } from './payment-webhook.service'; import { PaymentReconciliationService } from './payment-reconciliation.service'; import { DepartmentMappingService } from './department-mapping.service'; +import { AbandonedPaymentCleanupService } from './abandoned-payment-cleanup.service'; import { Payment, PaymentTransaction, @@ -12,6 +12,8 @@ import { } from '../database/entities'; import { EZPayService, PaymentsController } from './ezpay'; import { EmailModule } from '../email/email.module'; +import { FormsModule } from '../forms/forms.module'; +import { ProcessorsModule } from '../processors/processors.module'; @Module({ imports: [ @@ -22,17 +24,18 @@ import { EmailModule } from '../email/email.module'; ]), ScheduleModule.forRoot(), EmailModule, + forwardRef(() => FormsModule), + forwardRef(() => ProcessorsModule), ], providers: [ - PaymentIntegrationService, PaymentWebhookService, PaymentReconciliationService, EZPayService, DepartmentMappingService, + AbandonedPaymentCleanupService, ], controllers: [PaymentsController], exports: [ - PaymentIntegrationService, PaymentWebhookService, PaymentReconciliationService, DepartmentMappingService, diff --git a/src/processors/implementations/payment.processor.ts b/src/processors/implementations/payment.processor.ts index 801097d..8f5f0e7 100644 --- a/src/processors/implementations/payment.processor.ts +++ b/src/processors/implementations/payment.processor.ts @@ -14,6 +14,7 @@ import { ResponseDataConfig, } from '../../forms/interfaces/form-schema.interface'; import { IProcessor } from '../interfaces/processor.interface'; +import { encryptFormData } from '../../common/utils'; export interface PaymentProcessorResult { success: boolean; @@ -98,8 +99,6 @@ export class PaymentProcessor implements IProcessor { customerName: customerInfo.name, formId: context.formId, submissionId: context.submissionId, - confirmationEmailTo: config.confirmationEmailTo, - configCustomerEmail: config.customerEmail, formName: context.formName, }); @@ -158,11 +157,12 @@ export class PaymentProcessor implements IProcessor { successResult.paymentUrl, ); - // Create form submission payment link + // Create form submission payment link with encrypted form data await this.createFormSubmissionPayment( context.formId, context.submissionId, payment.id, + formData, ); this.logger.log( @@ -282,8 +282,6 @@ export class PaymentProcessor implements IProcessor { customerName: string; formId: string; submissionId: string; - confirmationEmailTo?: string[]; - configCustomerEmail?: string; formName?: string; }): Promise { // Include department in reference number for later API key resolution @@ -304,8 +302,6 @@ export class PaymentProcessor implements IProcessor { metadata: { formId: data.formId, submissionId: data.submissionId, - confirmationEmailTo: data.confirmationEmailTo, - configCustomerEmail: data.configCustomerEmail, formName: data.formName, }, }); @@ -340,64 +336,25 @@ export class PaymentProcessor implements IProcessor { formId: string, submissionId: string, paymentId: string, + formData: Record, ): Promise { + // Encrypt form data before storing + const encryptedData = encryptFormData(formData); + const formSubmissionPayment = this.formSubmissionPaymentRepository.create({ formId, submissionId, paymentId, paymentRequired: true, paymentCompleted: false, - paymentVerified: false, notificationSent: false, + encryptedFormData: encryptedData, + formDataDeleted: false, }); await this.formSubmissionPaymentRepository.save(formSubmissionPayment); } - /** - * Check if a form submission has payment requirements - */ - async hasPaymentProcessor(processors: any[]): Promise { - return processors.some((processor) => processor.type === 'payment'); - } - - /** - * Get payment status for a form submission - */ - async getPaymentStatus( - formId: string, - submissionId: string, - ): Promise<{ - hasPayment: boolean; - paymentRequired: boolean; - paymentCompleted: boolean; - paymentVerified: boolean; - payment?: Payment; - }> { - const formSubmissionPayment = - await this.formSubmissionPaymentRepository.findOne({ - where: { formId, submissionId }, - relations: ['payment'], - }); - - if (!formSubmissionPayment) { - return { - hasPayment: false, - paymentRequired: false, - paymentCompleted: false, - paymentVerified: false, - }; - } - - return { - hasPayment: true, - paymentRequired: formSubmissionPayment.paymentRequired, - paymentCompleted: formSubmissionPayment.paymentCompleted, - paymentVerified: formSubmissionPayment.paymentVerified, - payment: formSubmissionPayment.payment, - }; - } - /** * Process response data configuration to extract additional data from form data */