mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
7d3e3448cf
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.
281 lines
9.7 KiB
Go
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
|
|
}
|