Files
aura-crm/production/backend/internal/settings/settings.go
T
root 56ffca767d feat: offline license/trial system
7-day full-access trial, then gated behind an Ed25519-signed license key
(internal/license) verified entirely offline — no phone-home dependency.
Middleware 402s non-exempt API routes once the trial lapses with no valid
key; frontend shows a countdown banner in the last 3 days and a blocking
overlay once actually gated, both pointing at the new Settings -> Лицензия
tab. tools/gen-license mints keys for the vendor side (never ships the
private key).
2026-08-21 18:44:54 +00:00

234 lines
10 KiB
Go

// Package settings is the app's single mutable configuration record —
// business requisites (for PDF documents, internal/document), the Gemini
// API key/model (internal/aiintake, internal/analytics, internal/diagnosis),
// the Telegram bot token/chat/base URL (internal/notify), and the IMEI
// lookup API key (internal/imei). One singleton row in Postgres (see
// migrations/007_settings.sql) rather than env vars, so an owner can edit
// these from the Settings page without a redeploy.
//
// Deliberately excludes true infrastructure secrets — JWT_SECRET,
// DATABASE_URL, MINIO_*, CORE_URL/MODULE_TOKEN,
// ONLINE_STORE_WEBHOOK_TOKEN. Those are how this process authenticates to
// its own infrastructure (a DB credential can't live inside the database it
// unlocks) or would invalidate every staff session / break inter-service
// auth if changed live through a web form — they stay .env, by user
// decision.
//
// Fetch falls back to the matching env var whenever the DB field is empty,
// so an existing deployment keeps working unchanged the moment this
// migration runs, before any owner has touched the Settings page.
package settings
import (
"context"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
const defaultGeminiModel = "gemini-2.0-flash"
const defaultTGAPIBaseURL = "http://telegram-bot-api:8081"
type Settings struct {
BusinessName string `json:"business_name"`
BusinessINN string `json:"business_inn"`
BusinessKPP string `json:"business_kpp"`
BusinessAddress string `json:"business_address"`
BusinessPhone string `json:"business_phone"`
BusinessBankName string `json:"business_bank_name"`
BusinessBankAccount string `json:"business_bank_account"`
BusinessBankBIK string `json:"business_bank_bik"`
BusinessBankCorrAccount string `json:"business_bank_corr_account"`
GeminiAPIKey string `json:"gemini_api_key"`
GeminiModel string `json:"gemini_model"`
TGBotToken string `json:"tg_bot_token"`
TGChatID string `json:"tg_chat_id"`
TGAPIBaseURL string `json:"tg_api_base_url"`
IMEIAPIKey string `json:"imei_api_key"`
SMSProvider string `json:"sms_provider"`
SMSAPIID string `json:"sms_api_id"`
SMSFrom string `json:"sms_from"`
SMSEnabled bool `json:"sms_enabled"`
ClientTGBotUsername string `json:"client_tg_bot_username"`
TGWebhookSecret string `json:"tg_webhook_secret"`
ClientNotifyEnabled bool `json:"client_notify_enabled"`
PublicTrackingURL string `json:"public_tracking_url"`
LoyaltyEnabled bool `json:"loyalty_enabled"`
LoyaltyAccrualPercent string `json:"loyalty_accrual_percent"`
DefaultWarrantyDays int `json:"default_warranty_days"`
KkmEnabled bool `json:"kkm_enabled"`
KkmServerURL string `json:"kkm_server_url"`
KkmLogin string `json:"kkm_login"`
KkmPassword string `json:"kkm_password"`
KkmNumDevice string `json:"kkm_num_device"`
KkmTax string `json:"kkm_tax"`
DiscountApprovalEnabled bool `json:"discount_approval_enabled"`
DiscountApprovalThresholdPercent string `json:"discount_approval_threshold_percent"`
MaxBotToken string `json:"max_bot_token"`
MaxWebhookSecret string `json:"max_webhook_secret"`
ClientMaxBotUsername string `json:"client_max_bot_username"`
VkGroupToken string `json:"vk_group_token"`
VkGroupID string `json:"vk_group_id"`
VkSecretKey string `json:"vk_secret_key"`
VkConfirmationCode string `json:"vk_confirmation_code"`
ClientVkCommunityID string `json:"client_vk_community_id"`
DiaxProEmail string `json:"diaxpro_email"`
DiaxProPassword string `json:"diaxpro_password"`
// TrialStartedAt is stamped once by migration 067 the moment this
// database is first created — see that migration's doc comment for why
// that's a reliable first-boot timestamp with no separate detection
// code. LicenseKey is empty until an owner pastes one into Settings;
// see internal/license for verification.
TrialStartedAt time.Time `json:"trial_started_at"`
LicenseKey string `json:"license_key"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedByStaffName string `json:"updated_by_staff_name"`
}
// Fetch reads the singleton settings row, one indexed SELECT on a 1-row
// table — cheap enough to call at the top of every request handler that
// needs one of these values, rather than caching them at process start,
// so a change made in the Settings page takes effect immediately for the
// next request without a restart.
func Fetch(ctx context.Context, db *pgxpool.Pool) (Settings, error) {
var s Settings
// Every TEXT column is nullable (COALESCE'd to '' here) — a fresh row
// starts fully empty until an owner fills in the Settings page, and
// pgx can't scan a SQL NULL into a plain (non-pointer) Go string.
err := db.QueryRow(ctx, `
SELECT COALESCE(business_name, ''), COALESCE(business_inn, ''), COALESCE(business_kpp, ''),
COALESCE(business_address, ''), COALESCE(business_phone, ''),
COALESCE(business_bank_name, ''), COALESCE(business_bank_account, ''),
COALESCE(business_bank_bik, ''), COALESCE(business_bank_corr_account, ''),
COALESCE(gemini_api_key, ''), COALESCE(gemini_model, ''),
COALESCE(tg_bot_token, ''), COALESCE(tg_chat_id, ''), COALESCE(tg_api_base_url, ''),
COALESCE(imei_api_key, ''),
COALESCE(sms_provider, ''), COALESCE(sms_api_id, ''), COALESCE(sms_from, ''), sms_enabled,
COALESCE(client_tg_bot_username, ''), COALESCE(tg_webhook_secret, ''), client_notify_enabled,
COALESCE(public_tracking_url, ''),
loyalty_enabled, loyalty_accrual_percent::text, default_warranty_days,
kkm_enabled, COALESCE(kkm_server_url, ''), COALESCE(kkm_login, ''),
COALESCE(kkm_password, ''), COALESCE(kkm_num_device, ''), COALESCE(kkm_tax, ''),
discount_approval_enabled, discount_approval_threshold_percent::text,
COALESCE(max_bot_token, ''), COALESCE(max_webhook_secret, ''), COALESCE(client_max_bot_username, ''),
COALESCE(vk_group_token, ''), COALESCE(vk_group_id, ''), COALESCE(vk_secret_key, ''),
COALESCE(vk_confirmation_code, ''), COALESCE(client_vk_community_id, ''),
COALESCE(diaxpro_email, ''), COALESCE(diaxpro_password, ''),
trial_started_at, COALESCE(license_key, ''),
updated_at, COALESCE(updated_by_staff_name, '')
FROM settings WHERE id = 1`,
).Scan(
&s.BusinessName, &s.BusinessINN, &s.BusinessKPP, &s.BusinessAddress, &s.BusinessPhone,
&s.BusinessBankName, &s.BusinessBankAccount, &s.BusinessBankBIK, &s.BusinessBankCorrAccount,
&s.GeminiAPIKey, &s.GeminiModel, &s.TGBotToken, &s.TGChatID, &s.TGAPIBaseURL, &s.IMEIAPIKey,
&s.SMSProvider, &s.SMSAPIID, &s.SMSFrom, &s.SMSEnabled,
&s.ClientTGBotUsername, &s.TGWebhookSecret, &s.ClientNotifyEnabled,
&s.PublicTrackingURL,
&s.LoyaltyEnabled, &s.LoyaltyAccrualPercent, &s.DefaultWarrantyDays,
&s.KkmEnabled, &s.KkmServerURL, &s.KkmLogin, &s.KkmPassword, &s.KkmNumDevice, &s.KkmTax,
&s.DiscountApprovalEnabled, &s.DiscountApprovalThresholdPercent,
&s.MaxBotToken, &s.MaxWebhookSecret, &s.ClientMaxBotUsername,
&s.VkGroupToken, &s.VkGroupID, &s.VkSecretKey, &s.VkConfirmationCode, &s.ClientVkCommunityID,
&s.DiaxProEmail, &s.DiaxProPassword,
&s.TrialStartedAt, &s.LicenseKey,
&s.UpdatedAt, &s.UpdatedByStaffName,
)
if err != nil {
return Settings{}, err
}
applyEnvFallback(&s)
return s, nil
}
func applyEnvFallback(s *Settings) {
if s.BusinessName == "" {
s.BusinessName = os.Getenv("BUSINESS_NAME")
}
if s.BusinessINN == "" {
s.BusinessINN = os.Getenv("BUSINESS_INN")
}
if s.BusinessKPP == "" {
s.BusinessKPP = os.Getenv("BUSINESS_KPP")
}
if s.BusinessAddress == "" {
s.BusinessAddress = os.Getenv("BUSINESS_ADDRESS")
}
if s.BusinessPhone == "" {
s.BusinessPhone = os.Getenv("BUSINESS_PHONE")
}
if s.BusinessBankName == "" {
s.BusinessBankName = os.Getenv("BUSINESS_BANK_NAME")
}
if s.BusinessBankAccount == "" {
s.BusinessBankAccount = os.Getenv("BUSINESS_BANK_ACCOUNT")
}
if s.BusinessBankBIK == "" {
s.BusinessBankBIK = os.Getenv("BUSINESS_BANK_BIK")
}
if s.BusinessBankCorrAccount == "" {
s.BusinessBankCorrAccount = os.Getenv("BUSINESS_BANK_CORR_ACCOUNT")
}
if s.GeminiAPIKey == "" {
s.GeminiAPIKey = os.Getenv("GEMINI_API_KEY")
}
if s.GeminiModel == "" {
s.GeminiModel = os.Getenv("GEMINI_MODEL")
}
if s.GeminiModel == "" {
s.GeminiModel = defaultGeminiModel
}
if s.TGBotToken == "" {
s.TGBotToken = os.Getenv("TG_BOT_TOKEN")
}
if s.TGChatID == "" {
s.TGChatID = os.Getenv("TG_CHAT_ID")
}
if s.TGAPIBaseURL == "" {
s.TGAPIBaseURL = os.Getenv("TG_API_BASE_URL")
}
if s.TGAPIBaseURL == "" {
s.TGAPIBaseURL = defaultTGAPIBaseURL
}
if s.IMEIAPIKey == "" {
s.IMEIAPIKey = os.Getenv("IMEI_API_KEY")
}
if s.SMSProvider == "" {
s.SMSProvider = os.Getenv("SMS_PROVIDER")
}
if s.SMSProvider == "" {
s.SMSProvider = "smsru"
}
if s.SMSAPIID == "" {
s.SMSAPIID = os.Getenv("SMS_API_ID")
}
if s.SMSFrom == "" {
s.SMSFrom = os.Getenv("SMS_FROM")
}
if s.ClientTGBotUsername == "" {
s.ClientTGBotUsername = os.Getenv("CLIENT_TG_BOT_USERNAME")
}
if s.TGWebhookSecret == "" {
s.TGWebhookSecret = os.Getenv("TG_WEBHOOK_SECRET")
}
if s.PublicTrackingURL == "" {
s.PublicTrackingURL = os.Getenv("PUBLIC_TRACKING_URL")
}
}
// TrackingURL builds the public /track/:token link for a client
// notification, or "" if PublicTrackingURL isn't configured — every caller
// already treats an empty string as "omit the link" (see clientnotify's
// OrderCreated/StatusChanged/Ready templates), so a fetch failure here
// degrades the same way rather than blocking the notification entirely.
// Was three near-identical copies (order, cartridge, and now booking's own
// handlers) before being pulled out here.
func TrackingURL(ctx context.Context, db *pgxpool.Pool, token string) string {
s, err := Fetch(ctx, db)
if err != nil || s.PublicTrackingURL == "" {
return ""
}
return strings.TrimRight(s.PublicTrackingURL, "/") + "/" + token
}