feat(sms): implement SMS messaging and metered billing
CI / Frontend (push) Successful in 9m50s
CI / Go - apps/auth-service (push) Failing after 4s
CI / Go - apps/backend (push) Successful in 10m18s
CI / Docker publish - auth-service (push) Has been skipped
CI / Docker publish - backend (push) Has been skipped

Implement a complete SMS messaging system including:
- Integration with SMS Manager.cz API for sending messages.
- Metered billing via Stripe using monthly aggregate invoice items.
- Backend services for managing SMS settings, usage logging, and monthly reporting.
- Database migrations for tenant settings, usage logs, and monthly reports.
- Frontend dashboard components for SMS configuration, usage tracking, and history.
- Support for customer phone numbers in the booking flow.

Includes new migrations, backend services, and frontend UI components.
This commit is contained in:
Tomas Dvorak
2026-05-10 11:40:53 +02:00
parent 164a37e997
commit 7d3e3448cf
28 changed files with 3633 additions and 3190 deletions
@@ -458,3 +458,93 @@ func RenderReminderEmail(from string, job db.ReminderJobRecord) EmailMessage {
}
return RenderEmailMessage(data)
}
// ============================================
// SMS USAGE EMAIL TEMPLATE
// ============================================
type SMSUsageEmailData struct {
TenantName string
TenantSlug string
BusinessEmail string
YearMonth string
MessageCount int
TotalCostCents int
Locale string
}
func RenderSMSUsageEmail(data SMSUsageEmailData) EmailMessage {
cs := data.Locale == "cs"
year := data.YearMonth[:4]
month := data.YearMonth[5:]
monthLabel := month + "/" + year
if cs {
monthLabel = month + "." + year
}
totalFormatted := fmt.Sprintf("%.2f Kč", float64(data.TotalCostCents)/100.0)
subject := fmt.Sprintf("Bookra SMS Usage - %s (%s)", monthLabel, totalFormatted)
if cs {
subject = fmt.Sprintf("Bookra SMS Přehled - %s (%s)", monthLabel, totalFormatted)
}
textBody := fmt.Sprintf(
"SMS Usage Summary for %s\n\nPeriod: %s\nMessages sent: %d\nTotal cost: %s\n\nThis amount will be added to your next invoice.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
if cs {
textBody = fmt.Sprintf(
"Přehled SMS pro %s\n\nObdobí: %s\nOdeslaných zpráv: %d\nCelková cena: %s\n\nTato částka bude přidána k vaší další faktuře.",
data.TenantName, monthLabel, data.MessageCount, totalFormatted,
)
}
htmlBody := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1f2937;">
<div style="text-align:center;margin-bottom:24px;"><span style="font-size:32px;">📱</span></div>
<h2 style="text-align:center;color:#1f2937;margin-bottom:16px;">%s</h2>
<p style="color:#6b7280;text-align:center;margin-bottom:24px;">%s</p>
<table style="width:100%%;border-collapse:collapse;margin-bottom:24px;">
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%s</td>
</tr>
<tr style="border-bottom:1px solid #e5e7eb;">
<td style="padding:12px 0;color:#6b7280;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:600;">%d</td>
</tr>
<tr>
<td style="padding:12px 0;color:#1f2937;font-weight:600;">%s</td>
<td style="padding:12px 0;text-align:right;font-weight:700;font-size:18px;">%s</td>
</tr>
</table>
<p style="color:#9ca3af;font-size:14px;text-align:center;">%s</p>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">
<p style="color:#9ca3af;font-size:12px;text-align:center;">Powered by <a href="https://bookra.eu" style="color:#4f46e5;">Bookra</a></p>
</body></html>`,
ifCS(cs, "SMS Usage Summary", "Přehled SMS využití"),
fmt.Sprintf(ifCS(cs, "Your SMS usage for %s", "Vaše SMS využití za %s"), data.TenantName),
ifCS(cs, "Period", "Období"), monthLabel,
ifCS(cs, "Messages sent", "Odeslaných zpráv"), data.MessageCount,
ifCS(cs, "Total cost (excl. VAT)", "Celková cena (bez DPH)"), totalFormatted,
ifCS(cs, "This amount will be added to your next Stripe invoice.", "Tato částka bude přidána k vaší další faktuře Stripe."),
)
return EmailMessage{
From: "",
To: data.BusinessEmail,
Subject: subject,
Text: textBody,
HTML: htmlBody,
}
}
func ifCS(cs bool, en, csText string) string {
if cs {
return csText
}
return en
}
@@ -334,6 +334,14 @@ func (s *Service) SendUsageWarning(ctx context.Context, tenantID string, locatio
return err
}
// SendRawEmail sends a pre-built email message
func (s *Service) SendRawEmail(ctx context.Context, msg EmailMessage) (DeliveryReceipt, error) {
if msg.From == "" {
msg.From = s.cfg.EmailFrom
}
return s.emailProvider.Send(ctx, msg)
}
func (s *Service) SendTrialEndingEmail(ctx context.Context, tenantID string, daysRemaining int) error {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {