Files
Bookra/apps/backend/internal/db/sms.go
T
Tomas Dvorak 7d3e3448cf
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
feat(sms): implement SMS messaging and metered billing
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.
2026-05-10 11:40:53 +02:00

281 lines
9.7 KiB
Go

package db
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// ============================================
// SMS SETTINGS - PG REPOSITORY
// ============================================
func (r *PGRepository) GetTenantSMSSettings(ctx context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
var rec TenantSMSSettingsRecord
err := r.pool.QueryRow(ctx, `
SELECT tenant_id, enabled, COALESCE(sender_name, ''), COALESCE(monthly_limit, 0), COALESCE(stripe_subscription_item_id, '')
FROM tenant_sms_settings
WHERE tenant_id = $1
`, tenantID).Scan(&rec.TenantID, &rec.Enabled, &rec.SenderName, &rec.MonthlyLimit, &rec.StripeSubscriptionItemID)
if err != nil {
if err == pgx.ErrNoRows {
return TenantSMSSettingsRecord{TenantID: tenantID}, nil
}
return TenantSMSSettingsRecord{}, err
}
return rec, nil
}
func (r *PGRepository) UpsertTenantSMSSettings(ctx context.Context, params TenantSMSSettingsRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO tenant_sms_settings (tenant_id, enabled, sender_name, monthly_limit, stripe_subscription_item_id, updated_at)
VALUES ($1, $2, $3, $4, $5, now())
ON CONFLICT (tenant_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
sender_name = EXCLUDED.sender_name,
monthly_limit = EXCLUDED.monthly_limit,
stripe_subscription_item_id = EXCLUDED.stripe_subscription_item_id,
updated_at = now()
`, params.TenantID, params.Enabled, params.SenderName, params.MonthlyLimit, params.StripeSubscriptionItemID)
return err
}
// ============================================
// SMS USAGE LOGS - PG REPOSITORY
// ============================================
func (r *PGRepository) CreateSMSUsageLog(ctx context.Context, params SMSUsageLogRecord) (string, error) {
var id string
err := r.pool.QueryRow(ctx, `
INSERT INTO sms_usage_logs (tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, sent_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, params.TenantID, params.RecipientPhone, params.MessageBody, params.ExternalMessageID, params.ExternalRequestID, params.Status, params.CostCents, params.SentAt).Scan(&id)
return id, err
}
func (r *PGRepository) GetSMSUsageThisMonth(ctx context.Context, tenantID string) (SMSUsageSummary, error) {
var summary SMSUsageSummary
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND date_trunc('month', created_at) = date_trunc('month', now())
`, tenantID).Scan(&summary.MessageCount, &summary.TotalCostCents)
return summary, err
}
func (r *PGRepository) GetSMSUsageForMonth(ctx context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
var rec SMSMonthlyReportRecord
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(COUNT(*), 0), COALESCE(SUM(cost_cents), 0)
FROM sms_usage_logs
WHERE tenant_id = $1 AND to_char(created_at, 'YYYY-MM') = $2
`, tenantID, yearMonth).Scan(&rec.MessageCount, &rec.TotalCostCents)
if err != nil {
return SMSMonthlyReportRecord{}, err
}
rec.TenantID = tenantID
rec.YearMonth = yearMonth
return rec, nil
}
func (r *PGRepository) ListSMSUsageLogs(ctx context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, recipient_phone, message_body, external_message_id, external_request_id, status, cost_cents, created_at
FROM sms_usage_logs
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSUsageLogRecord
for rows.Next() {
var rec SMSUsageLogRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.RecipientPhone, &rec.MessageBody, &rec.ExternalMessageID, &rec.ExternalRequestID, &rec.Status, &rec.CostCents, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) ListSMSMonthlyReports(ctx context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id, invoice_sent_at, created_at
FROM sms_monthly_reports
WHERE tenant_id = $1
ORDER BY year_month DESC
LIMIT $2
`, tenantID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var records []SMSMonthlyReportRecord
for rows.Next() {
var rec SMSMonthlyReportRecord
if err := rows.Scan(&rec.ID, &rec.TenantID, &rec.YearMonth, &rec.MessageCount, &rec.TotalCostCents, &rec.StripeInvoiceID, &rec.InvoiceSentAt, &rec.CreatedAt); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO sms_monthly_reports (tenant_id, year_month, message_count, total_cost_cents, stripe_invoice_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, year_month) DO UPDATE SET
message_count = EXCLUDED.message_count,
total_cost_cents = EXCLUDED.total_cost_cents,
stripe_invoice_id = EXCLUDED.stripe_invoice_id,
created_at = now()
`, params.TenantID, params.YearMonth, params.MessageCount, params.TotalCostCents, params.StripeInvoiceID)
return err
}
func (r *PGRepository) MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error {
_, err := r.pool.Exec(ctx, `
UPDATE sms_monthly_reports
SET invoice_sent_at = now()
WHERE tenant_id = $1 AND year_month = $2
`, tenantID, yearMonth)
return err
}
func (r *PGRepository) ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT DISTINCT t.id, t.slug, t.name, t.preset, t.locale, t.timezone, t.plan_code, t.subscription_status,
t.billing_provider, t.billing_customer_id, t.billing_subscription_id
FROM tenants t
JOIN tenant_sms_settings s ON s.tenant_id = t.id AND s.enabled = true
JOIN sms_usage_logs l ON l.tenant_id = t.id AND to_char(l.created_at, 'YYYY-MM') = $1
`, yearMonth)
if err != nil {
return nil, err
}
defer rows.Close()
var records []TenantRecord
for rows.Next() {
var rec TenantRecord
if err := rows.Scan(&rec.ID, &rec.Slug, &rec.Name, &rec.Preset, &rec.Locale, &rec.Timezone, &rec.PlanCode, &rec.SubscriptionStatus,
&rec.BillingProvider, &rec.BillingCustomerID, &rec.BillingSubscription); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
// ============================================
// SMS SETTINGS - MEMORY REPOSITORY
// ============================================
func (r *MemoryRepository) GetTenantSMSSettings(_ context.Context, tenantID string) (TenantSMSSettingsRecord, error) {
if tenantID != r.tenant.ID {
return TenantSMSSettingsRecord{}, pgx.ErrNoRows
}
return r.smsSettings, nil
}
func (r *MemoryRepository) UpsertTenantSMSSettings(_ context.Context, params TenantSMSSettingsRecord) error {
r.smsSettings = params
return nil
}
func (r *MemoryRepository) CreateSMSUsageLog(_ context.Context, params SMSUsageLogRecord) (string, error) {
params.ID = fmt.Sprintf("sms-%d", len(r.smsLogs))
params.CreatedAt = time.Now().UTC()
r.smsLogs = append([]SMSUsageLogRecord{params}, r.smsLogs...)
return params.ID, nil
}
func (r *MemoryRepository) GetSMSUsageThisMonth(_ context.Context, tenantID string) (SMSUsageSummary, error) {
if tenantID != r.tenant.ID {
return SMSUsageSummary{}, nil
}
now := time.Now().UTC()
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Year() == now.Year() && log.CreatedAt.Month() == now.Month() {
count++
cost += log.CostCents
}
}
return SMSUsageSummary{MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) GetSMSUsageForMonth(_ context.Context, tenantID string, yearMonth string) (SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return SMSMonthlyReportRecord{}, nil
}
var count, cost int
for _, log := range r.smsLogs {
if log.TenantID == tenantID && log.CreatedAt.Format("2006-01") == yearMonth {
count++
cost += log.CostCents
}
}
return SMSMonthlyReportRecord{TenantID: tenantID, YearMonth: yearMonth, MessageCount: count, TotalCostCents: cost}, nil
}
func (r *MemoryRepository) ListSMSUsageLogs(_ context.Context, tenantID string, limit int) ([]SMSUsageLogRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsLogs) {
limit = len(r.smsLogs)
}
return r.smsLogs[:limit], nil
}
func (r *MemoryRepository) ListSMSMonthlyReports(_ context.Context, tenantID string, limit int) ([]SMSMonthlyReportRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
if limit > len(r.smsReports) {
limit = len(r.smsReports)
}
return r.smsReports[:limit], nil
}
func (r *MemoryRepository) UpsertSMSMonthlyReport(_ context.Context, params SMSMonthlyReportRecord) error {
for i, rep := range r.smsReports {
if rep.TenantID == params.TenantID && rep.YearMonth == params.YearMonth {
r.smsReports[i] = params
return nil
}
}
r.smsReports = append([]SMSMonthlyReportRecord{params}, r.smsReports...)
return nil
}
func (r *MemoryRepository) MarkSMSReportInvoiceSent(_ context.Context, tenantID string, yearMonth string) error {
now := time.Now().UTC()
for i, rep := range r.smsReports {
if rep.TenantID == tenantID && rep.YearMonth == yearMonth {
r.smsReports[i].InvoiceSentAt = &now
return nil
}
}
return nil
}
func (r *MemoryRepository) ListTenantsWithSMSUsage(_ context.Context, yearMonth string) ([]TenantRecord, error) {
for _, log := range r.smsLogs {
if log.TenantID == r.tenant.ID && log.CreatedAt.Format("2006-01") == yearMonth && r.smsSettings.Enabled {
return []TenantRecord{r.tenant}, nil
}
}
return nil, nil
}