- Add monthly/annual billing toggle with 25% annual discount on pricing page - Implement 14-day no-card free trial (server-side Stripe subscription creation) - Enable upgrade/downgrade via Stripe Customer Portal - Add admin-initiated ACH/invoice billing for enterprise customers - Add billing card to Settings page with plan info and Manage Billing button - Handle past_due status with read-only grace period access - Add trial ending and trial expired email templates - Add DB migration for billing_interval and collection_method columns - Update ONBOARDING-AND-AUTH.md documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
326 lines
14 KiB
TypeScript
326 lines
14 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { DataSource } from 'typeorm';
|
|
import { Resend } from 'resend';
|
|
|
|
@Injectable()
|
|
export class EmailService {
|
|
private readonly logger = new Logger(EmailService.name);
|
|
private resend: Resend | null = null;
|
|
private fromAddress: string;
|
|
private replyToAddress: string;
|
|
|
|
constructor(
|
|
private configService: ConfigService,
|
|
private dataSource: DataSource,
|
|
) {
|
|
const apiKey = this.configService.get<string>('RESEND_API_KEY');
|
|
if (apiKey && !apiKey.includes('placeholder')) {
|
|
this.resend = new Resend(apiKey);
|
|
this.logger.log('Resend email service initialized');
|
|
} else {
|
|
this.logger.warn('Resend not configured — emails will be logged only (stub mode)');
|
|
}
|
|
this.fromAddress = this.configService.get<string>('RESEND_FROM_ADDRESS') || 'noreply@hoaledgeriq.com';
|
|
this.replyToAddress = this.configService.get<string>('RESEND_REPLY_TO') || '';
|
|
}
|
|
|
|
// ─── Public API ──────────────────────────────────────────────
|
|
|
|
async sendActivationEmail(email: string, businessName: string, activationUrl: string): Promise<void> {
|
|
const subject = `Activate your ${businessName} account on HOA LedgerIQ`;
|
|
const html = this.buildTemplate({
|
|
preheader: 'Your HOA LedgerIQ account is ready to activate.',
|
|
heading: 'Welcome to HOA LedgerIQ!',
|
|
body: `
|
|
<p>Your organization <strong>${this.esc(businessName)}</strong> has been created and is ready to go.</p>
|
|
<p>Click the button below to set your password and activate your account:</p>
|
|
`,
|
|
ctaText: 'Activate My Account',
|
|
ctaUrl: activationUrl,
|
|
footer: 'This activation link expires in 72 hours. If you did not sign up for HOA LedgerIQ, please ignore this email.',
|
|
});
|
|
|
|
await this.send(email, subject, html, 'activation', { businessName, activationUrl });
|
|
}
|
|
|
|
async sendWelcomeEmail(email: string, businessName: string): Promise<void> {
|
|
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
|
const subject = `Welcome to HOA LedgerIQ — ${businessName}`;
|
|
const html = this.buildTemplate({
|
|
preheader: `${businessName} is all set up on HOA LedgerIQ.`,
|
|
heading: `You're all set!`,
|
|
body: `
|
|
<p>Your account for <strong>${this.esc(businessName)}</strong> is now active.</p>
|
|
<p>Log in to start managing your HOA's finances, assessments, and investments — all in one place.</p>
|
|
`,
|
|
ctaText: 'Go to Dashboard',
|
|
ctaUrl: `${appUrl}/dashboard`,
|
|
footer: 'If you have any questions, just reply to this email and we\'ll help you get started.',
|
|
});
|
|
|
|
await this.send(email, subject, html, 'welcome', { businessName });
|
|
}
|
|
|
|
async sendPaymentFailedEmail(email: string, businessName: string): Promise<void> {
|
|
const subject = `Action required: Payment failed for ${businessName}`;
|
|
const html = this.buildTemplate({
|
|
preheader: 'We were unable to process your payment.',
|
|
heading: 'Payment Failed',
|
|
body: `
|
|
<p>We were unable to process the latest payment for <strong>${this.esc(businessName)}</strong>.</p>
|
|
<p>Please update your payment method to avoid any interruption to your service.</p>
|
|
`,
|
|
ctaText: 'Update Payment Method',
|
|
ctaUrl: `${this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com'}/settings`,
|
|
footer: 'If you believe this is an error, please reply to this email and we\'ll look into it.',
|
|
});
|
|
|
|
await this.send(email, subject, html, 'payment_failed', { businessName });
|
|
}
|
|
|
|
async sendInviteMemberEmail(email: string, orgName: string, inviteUrl: string): Promise<void> {
|
|
const subject = `You've been invited to ${orgName} on HOA LedgerIQ`;
|
|
const html = this.buildTemplate({
|
|
preheader: `Join ${orgName} on HOA LedgerIQ.`,
|
|
heading: 'You\'re Invited!',
|
|
body: `
|
|
<p>You've been invited to join <strong>${this.esc(orgName)}</strong> on HOA LedgerIQ.</p>
|
|
<p>Click below to accept the invitation and set up your account:</p>
|
|
`,
|
|
ctaText: 'Accept Invitation',
|
|
ctaUrl: inviteUrl,
|
|
footer: 'This invitation link expires in 7 days. If you were not expecting this, please ignore this email.',
|
|
});
|
|
|
|
await this.send(email, subject, html, 'invite_member', { orgName, inviteUrl });
|
|
}
|
|
|
|
async sendTrialEndingEmail(email: string, businessName: string, daysRemaining: number, settingsUrl: string): Promise<void> {
|
|
const subject = `Your free trial ends in ${daysRemaining} days — ${businessName}`;
|
|
const html = this.buildTemplate({
|
|
preheader: `Your HOA LedgerIQ trial for ${businessName} is ending soon.`,
|
|
heading: `Your Trial Ends in ${daysRemaining} Days`,
|
|
body: `
|
|
<p>Your free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ ends in <strong>${daysRemaining} days</strong>.</p>
|
|
<p>To continue using all features without interruption, add a payment method before your trial expires.</p>
|
|
<p>If you don't add a payment method, your account will become read-only and you won't be able to make changes to your data.</p>
|
|
`,
|
|
ctaText: 'Add Payment Method',
|
|
ctaUrl: settingsUrl,
|
|
footer: 'If you have any questions about plans or pricing, just reply to this email.',
|
|
});
|
|
|
|
await this.send(email, subject, html, 'trial_ending', { businessName, daysRemaining, settingsUrl });
|
|
}
|
|
|
|
async sendTrialExpiredEmail(email: string, businessName: string): Promise<void> {
|
|
const appUrl = this.configService.get<string>('APP_URL') || 'https://app.hoaledgeriq.com';
|
|
const subject = `Your free trial has ended — ${businessName}`;
|
|
const html = this.buildTemplate({
|
|
preheader: `Your HOA LedgerIQ trial for ${businessName} has ended.`,
|
|
heading: 'Your Trial Has Ended',
|
|
body: `
|
|
<p>The free trial for <strong>${this.esc(businessName)}</strong> on HOA LedgerIQ has ended.</p>
|
|
<p>Your data is safe and your account is preserved. Subscribe to a plan to regain full access to your HOA financial management tools.</p>
|
|
`,
|
|
ctaText: 'Choose a Plan',
|
|
ctaUrl: `${appUrl}/pricing`,
|
|
footer: 'Your data will be preserved. You can reactivate your account at any time by subscribing to a plan.',
|
|
});
|
|
|
|
await this.send(email, subject, html, 'trial_expired', { businessName });
|
|
}
|
|
|
|
async sendPasswordResetEmail(email: string, resetUrl: string): Promise<void> {
|
|
const subject = 'Reset your HOA LedgerIQ password';
|
|
const html = this.buildTemplate({
|
|
preheader: 'Password reset requested for your HOA LedgerIQ account.',
|
|
heading: 'Password Reset',
|
|
body: `
|
|
<p>We received a request to reset your password. Click the button below to choose a new one:</p>
|
|
`,
|
|
ctaText: 'Reset Password',
|
|
ctaUrl: resetUrl,
|
|
footer: 'This link expires in 1 hour. If you did not request a password reset, please ignore this email — your password will remain unchanged.',
|
|
});
|
|
|
|
await this.send(email, subject, html, 'password_reset', { resetUrl });
|
|
}
|
|
|
|
// ─── Core send logic ────────────────────────────────────────
|
|
|
|
private async send(
|
|
toEmail: string,
|
|
subject: string,
|
|
html: string,
|
|
template: string,
|
|
metadata: Record<string, any>,
|
|
): Promise<void> {
|
|
// Always log to the database
|
|
await this.log(toEmail, subject, html, template, metadata);
|
|
|
|
if (!this.resend) {
|
|
this.logger.log(`📧 EMAIL STUB → ${toEmail}`);
|
|
this.logger.log(` Subject: ${subject}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await this.resend.emails.send({
|
|
from: this.fromAddress,
|
|
to: [toEmail],
|
|
replyTo: this.replyToAddress || undefined,
|
|
subject,
|
|
html,
|
|
});
|
|
|
|
if (result.error) {
|
|
this.logger.error(`Resend error for ${toEmail}: ${JSON.stringify(result.error)}`);
|
|
await this.updateLogStatus(toEmail, template, 'failed', result.error.message);
|
|
} else {
|
|
this.logger.log(`✅ Email sent to ${toEmail} (id: ${result.data?.id})`);
|
|
await this.updateLogStatus(toEmail, template, 'sent', result.data?.id);
|
|
}
|
|
} catch (err: any) {
|
|
this.logger.error(`Failed to send email to ${toEmail}: ${err.message}`);
|
|
await this.updateLogStatus(toEmail, template, 'failed', err.message);
|
|
}
|
|
}
|
|
|
|
// ─── Database logging ───────────────────────────────────────
|
|
|
|
private async log(
|
|
toEmail: string,
|
|
subject: string,
|
|
body: string,
|
|
template: string,
|
|
metadata: Record<string, any>,
|
|
): Promise<void> {
|
|
try {
|
|
await this.dataSource.query(
|
|
`INSERT INTO shared.email_log (to_email, subject, body, template, metadata)
|
|
VALUES ($1, $2, $3, $4, $5)`,
|
|
[toEmail, subject, body, template, JSON.stringify(metadata)],
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to log email: ${err}`);
|
|
}
|
|
}
|
|
|
|
private async updateLogStatus(toEmail: string, template: string, status: string, detail?: string): Promise<void> {
|
|
try {
|
|
await this.dataSource.query(
|
|
`UPDATE shared.email_log
|
|
SET metadata = metadata || $1::jsonb
|
|
WHERE to_email = $2 AND template = $3
|
|
AND created_at = (
|
|
SELECT MAX(created_at) FROM shared.email_log
|
|
WHERE to_email = $2 AND template = $3
|
|
)`,
|
|
[JSON.stringify({ send_status: status, send_detail: detail || '' }), toEmail, template],
|
|
);
|
|
} catch {
|
|
// Best effort — don't block the flow
|
|
}
|
|
}
|
|
|
|
// ─── HTML email template ────────────────────────────────────
|
|
|
|
private esc(text: string): string {
|
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
private buildTemplate(opts: {
|
|
preheader: string;
|
|
heading: string;
|
|
body: string;
|
|
ctaText: string;
|
|
ctaUrl: string;
|
|
footer: string;
|
|
}): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>${this.esc(opts.heading)}</title>
|
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
</head>
|
|
<body style="margin:0;padding:0;background-color:#f4f5f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
|
<!-- Preheader (hidden preview text) -->
|
|
<div style="display:none;max-height:0;overflow:hidden;">${this.esc(opts.preheader)}</div>
|
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;padding:24px 0;">
|
|
<tr>
|
|
<td align="center">
|
|
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;">
|
|
|
|
<!-- Logo bar -->
|
|
<tr>
|
|
<td align="center" style="padding:24px 0 16px;">
|
|
<span style="font-size:22px;font-weight:700;color:#1a73e8;letter-spacing:-0.5px;">
|
|
HOA LedgerIQ
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Main card -->
|
|
<tr>
|
|
<td>
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
|
|
style="background-color:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.08);">
|
|
<tr>
|
|
<td style="padding:40px 32px;">
|
|
<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#1a1a2e;">
|
|
${this.esc(opts.heading)}
|
|
</h1>
|
|
<div style="font-size:15px;line-height:1.6;color:#4a4a68;">
|
|
${opts.body}
|
|
</div>
|
|
|
|
<!-- CTA Button -->
|
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:28px 0 8px;">
|
|
<tr>
|
|
<td align="center" style="background-color:#1a73e8;border-radius:6px;">
|
|
<a href="${opts.ctaUrl}"
|
|
target="_blank"
|
|
style="display:inline-block;padding:14px 32px;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;border-radius:6px;">
|
|
${this.esc(opts.ctaText)}
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
|
|
<!-- Fallback URL -->
|
|
<p style="font-size:12px;color:#999;word-break:break-all;margin-top:16px;">
|
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
|
<a href="${opts.ctaUrl}" style="color:#1a73e8;">${opts.ctaUrl}</a>
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
|
|
<!-- Footer -->
|
|
<tr>
|
|
<td style="padding:24px 32px;text-align:center;">
|
|
<p style="font-size:12px;color:#999;line-height:1.5;margin:0;">
|
|
${this.esc(opts.footer)}
|
|
</p>
|
|
<p style="font-size:12px;color:#bbb;margin:12px 0 0;">
|
|
© ${new Date().getFullYear()} HOA LedgerIQ — Smart Financial Management for HOAs
|
|
</p>
|
|
</td>
|
|
</tr>
|
|
|
|
</table>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
}
|