7 Commits

48 changed files with 1003 additions and 233 deletions
+68
View File
@@ -0,0 +1,68 @@
name: Build and Deploy
on:
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Registry
uses: docker/login-action@v3
with:
registry: ${{ secrets.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push postgres image
uses: docker/build-push-action@v5
if: |
contains(github.event.commits[0].modified, 'infra/docker/postgres-pg-cron') ||
contains(github.event.commits[0].added, 'infra/docker/postgres-pg-cron')
with:
context: .
file: infra/docker/postgres-pg-cron/Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub-postgres:latest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build and push app image
uses: docker/build-push-action@v5
with:
context: .
file: infra/docker/application/Dockerfile
push: true
tags: |
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:latest
${{ secrets.REGISTRY_URL }}/${{ secrets.REGISTRY_USER }}/familyhub:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install kubectl
run: |
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
- name: Deploy to k3s
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG }}
run: |
mkdir -p ~/.kube
echo "$KUBECONFIG_DATA" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/application -n family-hub
kubectl rollout restart deployment/postgres -n family-hub
kubectl rollout status deployment/application -n family-hub --timeout=120s
kubectl rollout status deployment/postgres -n family-hub --timeout=120s
+2
View File
@@ -7,3 +7,5 @@ archive
volumes volumes
*.dtmp *.dtmp
*.gocache *.gocache
infra/k8s/secrets.yaml
infra/k8s/google-creds.yaml
@@ -0,0 +1 @@
DROP EXTENSION pg_cron;
+3 -2
View File
@@ -7,6 +7,7 @@ import (
"FamilyHub/src/domain" "FamilyHub/src/domain"
"errors" "errors"
"io" "io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@@ -73,13 +74,13 @@ func (router *TransactionsRouter) Create(c *gin.Context) {
c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()}) c.JSON(http.StatusBadRequest, dto.ErrorResponse{Message: err.Error()})
return return
} }
log.Printf("%+v\n", input)
transaction, err := router.creationService.Create(c.Request.Context(), input) transaction, err := router.creationService.Create(c.Request.Context(), input)
if err != nil { if err != nil {
handleTransactionError(c, err) handleTransactionError(c, err)
return return
} }
log.Printf("%+v\n", transaction)
c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction)) c.JSON(http.StatusCreated, dto.TransactionToResponse(transaction))
} }
+2
View File
@@ -128,6 +128,8 @@ func NewServer(cfg config.Config) *Server {
authRouter := routers.NewAuthRouter(authService) authRouter := routers.NewAuthRouter(authService)
authRouter.RegisterRouter(apiV1) authRouter.RegisterRouter(apiV1)
// подключаем статику Vue — должно быть последним
registerStaticFiles(router)
return &Server{ return &Server{
httpServer: &http.Server{ httpServer: &http.Server{
Addr: cfg.APIHost + ":" + cfg.APIPort, Addr: cfg.APIHost + ":" + cfg.APIPort,
+12 -1
View File
@@ -4,8 +4,10 @@ import (
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/integrations/receiptProvider" "FamilyHub/src/integrations/receiptProvider"
"FamilyHub/src/repositories" "FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context" "context"
"fmt" "fmt"
"log"
"strings" "strings"
) )
@@ -35,18 +37,23 @@ func (s *receiptService) AddReceipt(
ctx context.Context, ctx context.Context,
req domain.AddReceiptRequest, req domain.AddReceiptRequest,
) (*domain.Receipt, error) { ) (*domain.Receipt, error) {
log.Printf("receipt add request: payload=%s", utils.ToLogJSON(req))
receipt, err := s.provider.GetReceipt(ctx, req.Date, req.Number) receipt, err := s.provider.GetReceipt(ctx, req.Date, req.Number)
if err != nil { if err != nil {
log.Printf("receipt add failed: err=%v payload=%s", err, utils.ToLogJSON(req))
return nil, err return nil, err
} }
receiptID, err := s.repo.Create(ctx, receipt) receiptID, err := s.repo.Create(ctx, receipt)
if err != nil { if err != nil {
log.Printf("receipt persist failed: err=%v receipt=%s", err, utils.ToLogJSON(receipt))
return nil, err return nil, err
} }
receipt.ID = int(receiptID) receipt.ID = int(receiptID)
if !s.shouldCreateTransaction(req) { if !s.shouldCreateTransaction(req) {
log.Printf("receipt add response: payload=%s", utils.ToLogJSON(receipt))
return receipt, nil return receipt, nil
} }
@@ -59,6 +66,7 @@ func (s *receiptService) AddReceipt(
} }
receipt.TransactionID = &transaction.ID receipt.TransactionID = &transaction.ID
log.Printf("receipt add response: payload=%s", utils.ToLogJSON(receipt))
return receipt, nil return receipt, nil
} }
@@ -94,11 +102,14 @@ func (s *receiptService) createTransactionForReceipt(
CreatedBy: *req.CreatedBy, CreatedBy: *req.CreatedBy,
ReceiptID: &receiptID, ReceiptID: &receiptID,
} }
log.Printf("%+v\n", transaction)
log.Printf("receipt transaction create request: payload=%s", utils.ToLogJSON(transaction))
if err := s.transactionRepo.Create(ctx, transaction); err != nil { if err := s.transactionRepo.Create(ctx, transaction); err != nil {
log.Printf("receipt transaction create failed: err=%v payload=%s", err, utils.ToLogJSON(transaction))
return nil, err return nil, err
} }
log.Printf("receipt transaction create response: payload=%s", utils.ToLogJSON(transaction))
return transaction, nil return transaction, nil
} }
+8
View File
@@ -3,10 +3,12 @@ package services
import ( import (
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/repositories" "FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"log"
"strings" "strings"
) )
@@ -38,7 +40,10 @@ var (
) )
func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) { func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
log.Printf("transaction create request: payload=%s", utils.ToLogJSON(req))
if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" { if strings.TrimSpace(req.Type) == "" || strings.TrimSpace(req.Category) == "" {
log.Printf("transaction create failed: err=%v payload=%s", ErrInvalidTransaction, utils.ToLogJSON(req))
return nil, ErrInvalidTransaction return nil, ErrInvalidTransaction
} }
@@ -55,8 +60,10 @@ func (s *transactionService) Create(ctx context.Context, req domain.CreateTransa
if err := s.repo.Create(ctx, transaction); err != nil { if err := s.repo.Create(ctx, transaction); err != nil {
if errors.Is(err, repositories.ErrReceiptNotFound) { if errors.Is(err, repositories.ErrReceiptNotFound) {
log.Printf("transaction create failed: err=%v payload=%s", ErrReceiptNotFound, utils.ToLogJSON(req))
return nil, ErrReceiptNotFound return nil, ErrReceiptNotFound
} }
log.Printf("transaction create failed: err=%v payload=%s", err, utils.ToLogJSON(req))
return nil, err return nil, err
} }
@@ -72,6 +79,7 @@ func (s *transactionService) Create(ctx context.Context, req domain.CreateTransa
}) })
} }
log.Printf("transaction create response: payload=%s", utils.ToLogJSON(transaction))
return transaction, nil return transaction, nil
} }
+27
View File
@@ -0,0 +1,27 @@
package api
import (
"embed"
"io/fs"
"net/http"
"github.com/gin-gonic/gin"
)
//go:embed dist
var staticFiles embed.FS
func registerStaticFiles(router *gin.Engine) {
// вырезаем префикс dist/ чтобы / отдавал index.html
distFS, err := fs.Sub(staticFiles, "dist")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(distFS))
// все маршруты которые не /api и не /openapi — отдаём Vue
router.NoRoute(func(c *gin.Context) {
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
+29 -4
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"errors" "errors"
"fmt"
"os" "os"
"strings" "strings"
@@ -33,7 +34,6 @@ func Load() (Config, error) {
mode := os.Getenv("RUN_MODE") mode := os.Getenv("RUN_MODE")
debugMode := os.Getenv("DEBUG_MODE") == "true" debugMode := os.Getenv("DEBUG_MODE") == "true"
botToken := os.Getenv("BOT_TOKEN") botToken := os.Getenv("BOT_TOKEN")
dbConnectionString := os.Getenv("DB_PATH")
ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS") ocrTokenPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
apiPort := os.Getenv("API_PORT") apiPort := os.Getenv("API_PORT")
apiHost := os.Getenv("API_HOST") apiHost := os.Getenv("API_HOST")
@@ -42,6 +42,7 @@ func Load() (Config, error) {
openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT") openAPIEndpoint := os.Getenv("OPEN_API_ENDPOINT")
runMode, err := ParseRunMode(mode) runMode, err := ParseRunMode(mode)
dbConnectionString := buildConnectionString()
if err != nil { if err != nil {
warnings = append(warnings, err.Error()) warnings = append(warnings, err.Error())
} }
@@ -61,9 +62,6 @@ func Load() (Config, error) {
if apiSecret == "" { if apiSecret == "" {
warnings = append(warnings, "Missing required environment variable: API_SECRET") warnings = append(warnings, "Missing required environment variable: API_SECRET")
} }
if dbConnectionString == "" {
dbConnectionString = "sqlite://data/app.db"
}
if apiHost == "" { if apiHost == "" {
apiHost = "localhost" apiHost = "localhost"
} }
@@ -92,3 +90,30 @@ func Load() (Config, error) {
TelegramApi: "https://api.telegram.org", TelegramApi: "https://api.telegram.org",
}, nil }, nil
} }
func buildConnectionString() string {
// если задана готовая строка — используем её (удобно для локальной разработки через .env)
if dsn := os.Getenv("DB_PATH"); dsn != "" {
return dsn
}
// собираем из отдельных переменных (для Kubernetes)
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASSWORD")
dbName := os.Getenv("DB_NAME")
if host == "" || user == "" || password == "" || dbName == "" {
return ""
}
if port == "" {
port = "5432"
}
return fmt.Sprintf(
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
user, password, host, port, dbName,
)
}
+51 -18
View File
@@ -3,11 +3,14 @@ package familyHub
import ( import (
"FamilyHub/src/config" "FamilyHub/src/config"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"FamilyHub/src/utils"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
@@ -63,14 +66,13 @@ func (c *HTTPClient) SendReceipt(ctx context.Context, payload domain.AddReceiptR
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.transactions.create", body)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
if resp.StatusCode >= 300 { if statusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode) return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
return nil return nil
@@ -120,14 +122,13 @@ func (c *HTTPClient) RegisterUser(ctx context.Context, payload domain.CreateUser
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.users.create", body)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
if resp.StatusCode >= 300 { if statusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode) return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
return nil return nil
@@ -144,22 +145,21 @@ func (c *HTTPClient) GetUserByTelegramID(ctx context.Context, telegramID int64)
return nil, err return nil, err
} }
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.users.by_telegram", nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return nil, errUserNotFound return nil, errUserNotFound
} }
if resp.StatusCode >= 300 { if statusCode >= 300 {
return nil, fmt.Errorf("api error: status %d", resp.StatusCode) return nil, fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
var user domain.UserResponse var user domain.UserResponse
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { if err := json.Unmarshal(responseBody, &user); err != nil {
return nil, err return nil, err
} }
@@ -184,15 +184,48 @@ func (c *HTTPClient) CreateFamily(ctx context.Context, payload domain.CreateFami
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req) responseBody, statusCode, err := c.doRequest(req, "familyhub_api.families.create", body)
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close()
if resp.StatusCode >= 300 { if statusCode >= 300 {
return fmt.Errorf("api error: status %d", resp.StatusCode) return fmt.Errorf("api error: status %d body %s", statusCode, utils.TruncateForLog(string(responseBody), 512))
} }
return nil return nil
} }
func (c *HTTPClient) doRequest(req *http.Request, service string, requestBody []byte) ([]byte, int, error) {
log.Printf(
"external request: service=%s method=%s url=%s body=%q",
service,
req.Method,
req.URL.String(),
utils.TruncateForLog(string(requestBody), utils.DefaultLogValueLimit),
)
resp, err := c.client.Do(req)
if err != nil {
log.Printf("external response: service=%s method=%s url=%s err=%v", service, req.Method, req.URL.String(), err)
return nil, 0, err
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("external response: service=%s method=%s url=%s status=%d read_err=%v", service, req.Method, req.URL.String(), resp.StatusCode, readErr)
return nil, resp.StatusCode, readErr
}
log.Printf(
"external response: service=%s method=%s url=%s status=%d body=%q",
service,
req.Method,
req.URL.String(),
resp.StatusCode,
utils.TruncateForLog(string(responseBody), utils.DefaultLogValueLimit),
)
return responseBody, resp.StatusCode, nil
}
@@ -2,9 +2,14 @@ package familyHub
import ( import (
"FamilyHub/src/config" "FamilyHub/src/config"
"FamilyHub/src/utils"
"context" "context"
"fmt"
"io"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -18,19 +23,42 @@ func NewBotClient(config config.Config) (*HTTPClient, error) {
} }
func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error { func (c *HTTPClient) SendMessage(ctx context.Context, chatId int64, message string) error {
url := c.config.TelegramApi + "/bot" + c.config.BotToken + "/sendMessage?chat_id=" + strconv.FormatInt(chatId, 10) + "&text=" + message
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodGet, http.MethodGet,
c.config.TelegramApi+"/bot"+c.config.BotToken+"/sendMessage?chat_id="+strconv.FormatInt(chatId, 10)+"&text="+message, url,
nil, nil,
) )
if err != nil { if err != nil {
return err return err
} }
logURL := strings.ReplaceAll(req.URL.String(), c.config.BotToken, "***")
log.Printf(
"external request: service=telegram_bot.send_message method=%s url=%s body=%q",
http.MethodGet,
logURL,
utils.TruncateForLog(fmt.Sprintf("chat_id=%d&text=%s", chatId, message), utils.DefaultLogValueLimit),
)
resp, err := c.client.Do(req) resp, err := c.client.Do(req)
if err != nil { if err != nil {
log.Printf("external response: service=telegram_bot.send_message method=%s url=%s err=%v", http.MethodGet, logURL, err)
return err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("external response: service=telegram_bot.send_message method=%s url=%s status=%d read_err=%v", http.MethodGet, logURL, resp.StatusCode, readErr)
return readErr
}
log.Printf(
"external response: service=telegram_bot.send_message method=%s url=%s status=%d body=%q",
http.MethodGet,
logURL,
resp.StatusCode,
utils.TruncateForLog(string(responseBody), utils.DefaultLogValueLimit),
)
return nil return nil
} }
+12
View File
@@ -1,9 +1,11 @@
package ocr package ocr
import ( import (
"FamilyHub/src/utils"
"bytes" "bytes"
"context" "context"
"fmt" "fmt"
"log"
vision "cloud.google.com/go/vision/apiv1" vision "cloud.google.com/go/vision/apiv1"
) )
@@ -30,19 +32,29 @@ func (g *GoogleOCR) Close() error {
} }
func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) { func (g *GoogleOCR) Recognize(ctx context.Context, image []byte) (string, error) {
log.Printf("external request: service=google_ocr.detect_text image_size_bytes=%d", len(image))
img, err := vision.NewImageFromReader(bytes.NewReader(image)) img, err := vision.NewImageFromReader(bytes.NewReader(image))
if err != nil { if err != nil {
log.Printf("external response: service=google_ocr.detect_text err=%v", err)
return "", fmt.Errorf("load image: %w", err) return "", fmt.Errorf("load image: %w", err)
} }
annotations, err := g.client.DetectTexts(ctx, img, nil, 1) annotations, err := g.client.DetectTexts(ctx, img, nil, 1)
if err != nil { if err != nil {
log.Printf("external response: service=google_ocr.detect_text err=%v", err)
return "", fmt.Errorf("detect text: %w", err) return "", fmt.Errorf("detect text: %w", err)
} }
if len(annotations) == 0 { if len(annotations) == 0 {
log.Printf("external response: service=google_ocr.detect_text result=%q", "")
return "", nil return "", nil
} }
log.Printf(
"external response: service=google_ocr.detect_text result=%q annotations=%d",
utils.TruncateForLog(annotations[0].Description, utils.DefaultLogValueLimit),
len(annotations),
)
return annotations[0].Description, nil return annotations[0].Description, nil
} }
@@ -64,6 +64,14 @@ func (s *receiptProvider) GetReceipt(
var receipt domain.Receipt var receipt domain.Receipt
body, contentType := buildMultipartBody(date, number) body, contentType := buildMultipartBody(date, number)
requestBody := body.String()
log.Printf(
"external request: service=receipt_provider method=%s url=%s content_type=%s body=%q",
http.MethodPost,
url,
contentType,
utils.TruncateForLog(requestBody, utils.DefaultLogValueLimit),
)
httpReq, err := http.NewRequestWithContext( httpReq, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
@@ -79,18 +87,27 @@ func (s *receiptProvider) GetReceipt(
resp, err := s.client.Do(httpReq) resp, err := s.client.Do(httpReq)
if err != nil { if err != nil {
log.Println(err.Error()) log.Printf("external response: service=receipt_provider method=%s url=%s err=%v", http.MethodPost, url, err)
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
responseBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
log.Printf("failed to read external service response body: %v", readErr)
return nil, readErr
}
bodyText := strings.TrimSpace(string(responseBody))
log.Printf(
"external response: service=receipt_provider method=%s url=%s status=%d body=%q",
http.MethodPost,
url,
resp.StatusCode,
utils.TruncateForLog(bodyText, utils.DefaultLogValueLimit),
)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
log.Printf("failed to read external service error body: %v", readErr)
}
bodyText := strings.TrimSpace(string(responseBody))
log.Printf("external service returned %d body=%q", resp.StatusCode, bodyText)
return nil, &ExternalServiceError{ return nil, &ExternalServiceError{
StatusCode: resp.StatusCode, StatusCode: resp.StatusCode,
Body: bodyText, Body: bodyText,
@@ -100,8 +117,8 @@ func (s *receiptProvider) GetReceipt(
var raw struct { var raw struct {
Message map[string]interface{} `json:"message"` Message map[string]interface{} `json:"message"`
} }
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { if err := json.Unmarshal(responseBody, &raw); err != nil {
log.Printf("external service returned %s\n", err.Error()) log.Printf("external service returned invalid json: %v", err)
return nil, err return nil, err
} }
@@ -112,6 +129,7 @@ func (s *receiptProvider) GetReceipt(
} }
if receipt.IssuedAtRaw == "" { if receipt.IssuedAtRaw == "" {
log.Printf("external response parse failed: service=receipt_provider err=%v date=%s number=%s", ErrReceiptNotFound, date, number)
return nil, ErrReceiptNotFound return nil, ErrReceiptNotFound
} }
+91 -32
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"log"
"FamilyHub/src/domain" "FamilyHub/src/domain"
) )
@@ -25,29 +26,63 @@ func NewReceiptsSQLRepository(db *sql.DB) *ReceiptsSQLRepository {
} }
func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) { func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Receipt) (int64, error) {
log.Printf("%+v\n", receipt)
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer tx.Rollback() defer tx.Rollback()
if receipt.ReceiptNumber != receipt.UI { if receipt.ReceiptNumber != receipt.UI {
receipt.ReceiptNumber = receipt.UI receipt.ReceiptNumber = receipt.UI
} }
res, err := tx.ExecContext(ctx, `
log.Println("First query")
query := `
INSERT INTO receipts ( INSERT INTO receipts (
transaction_id, receipt_number, ui, status, issued_at, transaction_id,
total_amount, payment_amount, cash_amount, receipt_number,
another_amount, clearing_amount, margin, ui,
currency, payment_type, status,
cashbox_number, cashier, issued_at,
name_spd, name_to, name_np, type_np, total_amount,
street_to, house_to, payment_amount,
kod_soato, oblast_soato, rayon_soato, selsovet_soato, cash_amount,
doc_num, skno_number, unp, another_amount,
clearing_amount,
margin,
currency,
payment_type,
cashbox_number,
cashier,
name_spd,
name_to,
name_np,
type_np,
street_to,
house_to,
kod_soato,
oblast_soato,
rayon_soato,
selsovet_soato,
doc_num,
skno_number,
unp,
success success
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) )
`, VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14, $15,
$16, $17, $18, $19, $20,
$21, $22, $23, $24, $25,
$26, $27, $28, $29
)
RETURNING id;
`
args := []any{
receipt.TransactionID, receipt.TransactionID,
receipt.ReceiptNumber, receipt.ReceiptNumber,
receipt.UI, receipt.UI,
@@ -85,16 +120,19 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
receipt.UNP, receipt.UNP,
receipt.Success, receipt.Success,
) }
log.Printf("SQL: %s", query)
log.Printf("ARGS: %+v", args)
var receiptID int64
err = tx.QueryRowContext(ctx, query, args...).Scan(&receiptID)
if err != nil { if err != nil {
return 0, err return 0, err
} }
receiptID, err := res.LastInsertId() log.Println("Second query")
if err != nil {
return 0, err
}
stmt, err := tx.PrepareContext(ctx, ` stmt, err := tx.PrepareContext(ctx, `
INSERT INTO positions ( INSERT INTO positions (
@@ -109,7 +147,11 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
tag, tag,
marking_code, marking_code,
ukz_code ukz_code
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) )
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10, $11
)
`) `)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -117,7 +159,8 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
defer stmt.Close() defer stmt.Close()
for _, p := range receipt.Positions { for _, p := range receipt.Positions {
_, err = stmt.ExecContext(ctx, _, err = stmt.ExecContext(
ctx,
receiptID, receiptID,
p.SectionNumber, p.SectionNumber,
p.GTINCode, p.GTINCode,
@@ -135,7 +178,11 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
} }
} }
return receiptID, tx.Commit() if err = tx.Commit(); err != nil {
return 0, err
}
return receiptID, nil
} }
func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) { func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) {
@@ -157,7 +204,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
doc_num, skno_number, unp, doc_num, skno_number, unp,
success success
FROM receipts FROM receipts
WHERE id = ? WHERE id = $1
`, id).Scan( `, id).Scan(
&receipt.ID, &receipt.ID,
&receipt.TransactionID, &receipt.TransactionID,
@@ -213,7 +260,7 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
product_count, amount, product_count, amount,
discount, surcharge, discount, surcharge,
tag, marking_code, ukz_code tag, marking_code, ukz_code
FROM positions WHERE receipt_id = ? FROM positions WHERE receipt_id = $1
`, id) `, id)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -247,10 +294,16 @@ func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.
func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) { func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) ([]*domain.Receipt, error) {
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, transaction_id, receipt_number, issued_at, total_amount, currency SELECT
id,
transaction_id,
receipt_number,
issued_at,
total_amount,
currency
FROM receipts FROM receipts
ORDER BY issued_at DESC ORDER BY issued_at DESC
LIMIT ? OFFSET ? LIMIT $1 OFFSET $2
`, limit, offset) `, limit, offset)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -261,6 +314,7 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
for rows.Next() { for rows.Next() {
var rct domain.Receipt var rct domain.Receipt
if err := rows.Scan( if err := rows.Scan(
&rct.ID, &rct.ID,
&rct.TransactionID, &rct.TransactionID,
@@ -271,9 +325,14 @@ func (r *ReceiptsSQLRepository) GetAll(ctx context.Context, limit, offset int) (
); err != nil { ); err != nil {
return nil, err return nil, err
} }
receipts = append(receipts, &rct) receipts = append(receipts, &rct)
} }
if err := rows.Err(); err != nil {
return nil, err
}
return receipts, nil return receipts, nil
} }
@@ -287,11 +346,11 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
UPDATE receipts SET UPDATE receipts SET
transaction_id = ?, transaction_id = $1,
issued_at = ?, issued_at = $2,
total_amount = ?, total_amount = $3,
currency = ? currency = $4
WHERE id = ? WHERE id = $5
`, `,
receipt.TransactionID, receipt.TransactionID,
receipt.IssuedAt, receipt.IssuedAt,
@@ -303,7 +362,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
return err return err
} }
_, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = ?`, receipt.ID) _, err = tx.ExecContext(ctx, `DELETE FROM positions WHERE receipt_id = $1`, receipt.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -312,7 +371,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
_, err = tx.ExecContext(ctx, ` _, err = tx.ExecContext(ctx, `
INSERT INTO positions ( INSERT INTO positions (
receipt_id, product_name, product_count, amount receipt_id, product_name, product_count, amount
) VALUES (?, ?, ?, ?) ) VALUES ($1, $2, $3, $4)
`, receipt.ID, p.ProductName, p.ProductCount, p.Amount) `, receipt.ID, p.ProductName, p.ProductCount, p.Amount)
if err != nil { if err != nil {
return err return err
@@ -324,7 +383,7 @@ func (r *ReceiptsSQLRepository) Update(ctx context.Context, receipt *domain.Rece
func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error { func (r *ReceiptsSQLRepository) Delete(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`DELETE FROM receipts WHERE id = ?`, `DELETE FROM receipts WHERE id = $1`,
id, id,
) )
return err return err
+29
View File
@@ -0,0 +1,29 @@
package utils
import (
"encoding/json"
"fmt"
)
const DefaultLogValueLimit = 4096
func ToLogJSON(value any) string {
if value == nil {
return "null"
}
data, err := json.Marshal(value)
if err != nil {
return TruncateForLog(fmt.Sprintf("%+v", value), DefaultLogValueLimit)
}
return TruncateForLog(string(data), DefaultLogValueLimit)
}
func TruncateForLog(value string, limit int) string {
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit] + "...(truncated)"
}
+1
View File
@@ -0,0 +1 @@
25.8.2
+6 -1
View File
@@ -23,5 +23,10 @@
"overrides": { "overrides": {
"vite": "6.3.5" "vite": "6.3.5"
} }
} },
"engines": {
"node": "^25.5.2",
"npm": ">=10"
},
"packageManager": "npm@11.11.1"
} }
+1
View File
@@ -57,6 +57,7 @@ onMounted(() => {
<template> <template>
<FinanceScreen <FinanceScreen
v-if="activeScreen === 'finance'" v-if="activeScreen === 'finance'"
:family-id="Number.isFinite(configuredFamilyId) && configuredFamilyId > 0 ? configuredFamilyId : undefined"
@navigate="handleNavigate" @navigate="handleNavigate"
/> />
+43
View File
@@ -0,0 +1,43 @@
export interface Transaction {
id: number
family_id: number
description: string | null
type: string
datetime: string
category: string
amount: number
created_at: string
created_by: number
receipt_id: number | null
}
interface TransactionsResponse {
items: Transaction[]
}
interface GetTransactionsOptions {
familyId?: number
limit?: number
offset?: number
}
export async function getTransactions(options: GetTransactionsOptions = {}): Promise<Transaction[]> {
const params = new URLSearchParams()
if (typeof options.familyId === 'number' && Number.isFinite(options.familyId) && options.familyId > 0) {
params.set('family_id', String(options.familyId))
}
params.set('limit', String(options.limit ?? 100))
params.set('offset', String(options.offset ?? 0))
const query = params.toString()
const response = await fetch(`/api/v1/transactions${query ? `?${query}` : ''}`)
if (!response.ok) {
throw new Error(`Failed to fetch transactions: ${response.status}`)
}
const payload = await response.json() as TransactionsResponse
return Array.isArray(payload.items) ? payload.items : []
}
+6 -1
View File
@@ -13,6 +13,11 @@ type Tab = 'transactions' | 'analytics' | 'categories';
const emit = defineEmits<{ const emit = defineEmits<{
navigate: [screen: string]; navigate: [screen: string];
}>(); }>();
defineProps<{
familyId?: number;
}>();
const { t } = useI18n(); const { t } = useI18n();
const activeTab = ref<Tab>('transactions'); const activeTab = ref<Tab>('transactions');
@@ -76,7 +81,7 @@ const tabs = computed<Array<{ id: Tab; label: string }>>(() => [
</div> </div>
</div> </div>
<TransactionsList v-if="activeTab === 'transactions'" /> <TransactionsList v-if="activeTab === 'transactions'" :family-id="familyId" />
<AnalyticsView v-else-if="activeTab === 'analytics'" /> <AnalyticsView v-else-if="activeTab === 'analytics'" />
<CategoriesView v-else /> <CategoriesView v-else />
</main> </main>
+244 -134
View File
@@ -1,21 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { type Component } from 'vue'; import { computed, ref, watch, type Component } from 'vue';
import { import {
Car, Car,
Coffee, Coffee,
CircleDollarSign,
Film, Film,
Heart, Heart,
Home, Home,
Receipt,
ShoppingBag, ShoppingBag,
TrendingUp, TrendingUp,
Utensils, Utensils,
} from 'lucide-vue-next'; } from 'lucide-vue-next';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import { getTransactions, type Transaction as ApiTransaction } from '../api/transactions';
interface Transaction { interface TransactionViewModel {
id: string; id: number;
title: string; title: string;
categoryKey: string; categoryLabel: string;
amount: number; amount: number;
type: 'income' | 'expense'; type: 'income' | 'expense';
icon: Component; icon: Component;
@@ -24,12 +27,17 @@ interface Transaction {
} }
interface TransactionGroup { interface TransactionGroup {
dateKey: string; id: string;
label: string;
total: number; total: number;
transactions: Transaction[]; transactions: TransactionViewModel[];
} }
const { t } = useI18n(); const props = defineProps<{
familyId?: number;
}>();
const { locale, t } = useI18n();
const colorMap = { const colorMap = {
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' }, emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
@@ -42,144 +50,246 @@ const colorMap = {
indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' }, indigo: { bg: 'bg-indigo-500/10', text: 'text-indigo-400' },
} as const; } as const;
const transactionGroups: TransactionGroup[] = [ const transactions = ref<ApiTransaction[]>([]);
{ const isLoading = ref(false);
dateKey: 'finance.transactions.today', const errorMessage = ref('');
total: -245.5,
transactions: [ const categoryPresentationMap: Record<string, { icon: Component; color: keyof typeof colorMap; labelKey?: string }> = {
{ groceries: { icon: ShoppingBag, color: 'emerald', labelKey: 'finance.category.groceries' },
id: '1', shopping: { icon: ShoppingBag, color: 'emerald' },
title: 'Whole Foods Market', food: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
categoryKey: 'finance.category.groceries', food_dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
amount: -124.5, dining: { icon: Utensils, color: 'orange', labelKey: 'finance.category.foodDining' },
type: 'expense', coffee: { icon: Coffee, color: 'amber', labelKey: 'finance.category.coffee' },
icon: ShoppingBag, income: { icon: TrendingUp, color: 'blue', labelKey: 'finance.category.income' },
color: 'emerald', transport: { icon: Car, color: 'red', labelKey: 'finance.category.transport' },
time: '2:30 PM', entertainment: { icon: Film, color: 'purple', labelKey: 'finance.category.entertainment' },
}, donation: { icon: Heart, color: 'pink', labelKey: 'finance.category.donation' },
{ housing: { icon: Home, color: 'indigo', labelKey: 'finance.category.housing' },
id: '2', rent: { icon: Home, color: 'indigo' },
title: 'Uber Eats', receipt: { icon: Receipt, color: 'blue' },
categoryKey: 'finance.category.foodDining', };
amount: -45,
type: 'expense', const intlLocale = computed(() => (locale.value === 'ru' ? 'ru-RU' : 'en-US'));
icon: Utensils, const currencyFormatter = computed(() => new Intl.NumberFormat(intlLocale.value, {
color: 'orange', style: 'currency',
time: '12:15 PM', currency: 'USD',
}, minimumFractionDigits: 2,
{ maximumFractionDigits: 2,
id: '3', }));
title: 'Starbucks',
categoryKey: 'finance.category.coffee', const transactionGroups = computed<TransactionGroup[]>(() => {
amount: -12.5, const groups = new Map<string, TransactionGroup>();
type: 'expense',
icon: Coffee, for (const transaction of transactions.value) {
color: 'amber', const date = new Date(transaction.datetime);
time: '9:00 AM', const groupId = getGroupId(date);
}, const signedAmount = getSignedAmount(transaction);
{
id: '4', if (!groups.has(groupId)) {
title: 'Freelance Payment', groups.set(groupId, {
categoryKey: 'finance.category.income', id: groupId,
amount: 850, label: formatGroupLabel(date),
type: 'income', total: 0,
icon: TrendingUp, transactions: [],
color: 'blue', });
time: '8:00 AM', }
},
], const group = groups.get(groupId);
if (!group) {
continue;
}
const presentation = getCategoryPresentation(transaction.category, transaction.type);
group.total += signedAmount;
group.transactions.push({
id: transaction.id,
title: getTransactionTitle(transaction),
categoryLabel: getCategoryLabel(transaction.category),
amount: transaction.amount,
type: transaction.type === 'income' ? 'income' : 'expense',
icon: presentation.icon,
color: presentation.color,
time: formatTime(date),
});
}
return Array.from(groups.values());
});
async function loadTransactions() {
isLoading.value = true;
errorMessage.value = '';
try {
transactions.value = await getTransactions({ familyId: props.familyId });
} catch (error) {
console.error('Failed to load transactions', error);
errorMessage.value = t('finance.transactions.error');
transactions.value = [];
} finally {
isLoading.value = false;
}
}
function getSignedAmount(transaction: ApiTransaction): number {
return transaction.type === 'income' ? transaction.amount : -transaction.amount;
}
function getCategoryPresentation(category: string, type: string) {
const normalizedCategory = normalizeKey(category);
if (type === 'income') {
return categoryPresentationMap.income;
}
return categoryPresentationMap[normalizedCategory] ?? { icon: CircleDollarSign, color: 'blue' as const };
}
function getCategoryLabel(category: string): string {
const presentation = categoryPresentationMap[normalizeKey(category)];
if (presentation?.labelKey) {
return t(presentation.labelKey);
}
return humanizeCategory(category);
}
function getTransactionTitle(transaction: ApiTransaction): string {
const description = transaction.description?.trim();
if (description) {
return description;
}
return getCategoryLabel(transaction.category) || t('finance.transactions.untitled');
}
function getGroupId(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatGroupLabel(date: Date): string {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (isSameDate(date, today)) {
return t('finance.transactions.today');
}
if (isSameDate(date, yesterday)) {
return t('finance.transactions.yesterday');
}
const options: Intl.DateTimeFormatOptions = date.getFullYear() === today.getFullYear()
? { day: 'numeric', month: 'short' }
: { day: 'numeric', month: 'short', year: 'numeric' };
return new Intl.DateTimeFormat(intlLocale.value, options).format(date);
}
function formatTime(date: Date): string {
return new Intl.DateTimeFormat(intlLocale.value, {
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
function formatAmount(amount: number): string {
return currencyFormatter.value.format(Math.abs(amount));
}
function isSameDate(left: Date, right: Date): boolean {
return left.getFullYear() === right.getFullYear()
&& left.getMonth() === right.getMonth()
&& left.getDate() === right.getDate();
}
function normalizeKey(value: string): string {
return value.trim().toLowerCase().replace(/[\s-]+/g, '_');
}
function humanizeCategory(value: string): string {
return value
.trim()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
watch(
() => props.familyId,
() => {
void loadTransactions();
}, },
{ { immediate: true },
dateKey: 'finance.transactions.yesterday', );
total: -89.99,
transactions: [
{
id: '5',
title: 'Shell Gas Station',
categoryKey: 'finance.category.transport',
amount: -65,
type: 'expense',
icon: Car,
color: 'red',
time: '6:45 PM',
},
{
id: '6',
title: 'Netflix Subscription',
categoryKey: 'finance.category.entertainment',
amount: -15.99,
type: 'expense',
icon: Film,
color: 'purple',
time: '12:00 PM',
},
{
id: '7',
title: 'Charity Donation',
categoryKey: 'finance.category.donation',
amount: -25,
type: 'expense',
icon: Heart,
color: 'pink',
time: '10:30 AM',
},
],
},
{
dateKey: 'finance.transactions.apr1',
total: -1250,
transactions: [
{
id: '8',
title: 'Rent Payment',
categoryKey: 'finance.category.housing',
amount: -1250,
type: 'expense',
icon: Home,
color: 'indigo',
time: '9:00 AM',
},
],
},
];
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div v-for="group in transactionGroups" :key="group.dateKey"> <div
<div class="mb-3 flex items-center justify-between px-1"> v-if="isLoading"
<h3 class="text-[14px] font-semibold text-white">{{ t(group.dateKey) }}</h3> class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
<span :class="['text-[13px] font-semibold', group.total >= 0 ? 'text-emerald-400' : 'text-zinc-400']"> >
{{ group.total >= 0 ? '+' : '' }}${{ Math.abs(group.total).toFixed(2) }} {{ t('finance.transactions.loading') }}
</span> </div>
</div>
<div class="space-y-2"> <div
<div v-else-if="errorMessage"
v-for="transaction in group.transactions" class="rounded-[16px] border border-rose-500/20 bg-rose-500/10 px-4 py-6 text-center text-[14px] text-rose-200"
:key="transaction.id" >
class="group flex cursor-pointer items-center gap-3 rounded-[16px] border border-white/[0.06] bg-[#16161F] p-3.5 transition-all hover:border-white/[0.1] hover:bg-[#1A1A24]" {{ errorMessage }}
> </div>
<div :class="['flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[13px] transition-transform group-hover:scale-110', colorMap[transaction.color].bg]">
<component :is="transaction.icon" :class="['h-[19px] w-[19px]', colorMap[transaction.color].text]" :stroke-width="2" />
</div>
<div class="min-w-0 flex-1"> <div
<p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p> v-else-if="transactionGroups.length === 0"
<div class="flex items-center gap-2"> class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
<span class="text-[12px] text-zinc-500">{{ t(transaction.categoryKey) }}</span> >
<span class="text-zinc-700"></span> {{ t('finance.transactions.empty') }}
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span> </div>
<template v-else>
<div v-for="group in transactionGroups" :key="group.id">
<div class="mb-3 flex items-center justify-between px-1">
<h3 class="text-[14px] font-semibold text-white">{{ group.label }}</h3>
<span :class="['text-[13px] font-semibold', group.total >= 0 ? 'text-emerald-400' : 'text-zinc-400']">
{{ group.total >= 0 ? '+' : '-' }}{{ formatAmount(group.total) }}
</span>
</div>
<div class="space-y-2">
<div
v-for="transaction in group.transactions"
:key="transaction.id"
class="group flex cursor-pointer items-center gap-3 rounded-[16px] border border-white/[0.06] bg-[#16161F] p-3.5 transition-all hover:border-white/[0.1] hover:bg-[#1A1A24]"
>
<div :class="['flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[13px] transition-transform group-hover:scale-110', colorMap[transaction.color].bg]">
<component :is="transaction.icon" :class="['h-[19px] w-[19px]', colorMap[transaction.color].text]" :stroke-width="2" />
</div> </div>
</div>
<div class="text-right"> <div class="min-w-0 flex-1">
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']"> <p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p>
{{ transaction.type === 'income' ? '+' : '-' }}${{ Math.abs(transaction.amount).toFixed(2) }} <div class="flex items-center gap-2">
</p> <span class="text-[12px] text-zinc-500">{{ transaction.categoryLabel }}</span>
<span class="text-zinc-700"></span>
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span>
</div>
</div>
<div class="text-right">
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']">
{{ transaction.type === 'income' ? '+' : '-' }}{{ formatAmount(transaction.amount) }}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </template>
</div> </div>
</template> </template>
+8
View File
@@ -75,6 +75,10 @@ const messages: Record<Locale, Messages> = {
'finance.transactions.today': 'Today', 'finance.transactions.today': 'Today',
'finance.transactions.yesterday': 'Yesterday', 'finance.transactions.yesterday': 'Yesterday',
'finance.transactions.apr1': 'Apr 1', 'finance.transactions.apr1': 'Apr 1',
'finance.transactions.loading': 'Loading transactions...',
'finance.transactions.empty': 'No transactions yet',
'finance.transactions.error': 'Failed to load transactions',
'finance.transactions.untitled': 'Transaction',
'finance.category.groceries': 'Groceries', 'finance.category.groceries': 'Groceries',
'finance.category.foodDining': 'Food & Dining', 'finance.category.foodDining': 'Food & Dining',
'finance.category.coffee': 'Coffee', 'finance.category.coffee': 'Coffee',
@@ -217,6 +221,10 @@ const messages: Record<Locale, Messages> = {
'finance.transactions.today': 'Сегодня', 'finance.transactions.today': 'Сегодня',
'finance.transactions.yesterday': 'Вчера', 'finance.transactions.yesterday': 'Вчера',
'finance.transactions.apr1': '1 апр', 'finance.transactions.apr1': '1 апр',
'finance.transactions.loading': 'Загрузка транзакций...',
'finance.transactions.empty': 'Транзакций пока нет',
'finance.transactions.error': 'Не удалось загрузить транзакции',
'finance.transactions.untitled': 'Транзакция',
'finance.category.groceries': 'Продукты', 'finance.category.groceries': 'Продукты',
'finance.category.foodDining': 'Еда и рестораны', 'finance.category.foodDining': 'Еда и рестораны',
'finance.category.coffee': 'Кофе', 'finance.category.coffee': 'Кофе',
+45
View File
@@ -0,0 +1,45 @@
# ================================
# Stage 1: сборка Vue
# ================================
FROM node:20-alpine AS frontend
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# ================================
# Stage 2: сборка Go
# ================================
FROM golang:1.26-bookworm AS backend
WORKDIR /app
# зависимости отдельно — используем кэш слоёв
COPY backend/go.mod backend/go.sum ./
RUN go mod download
# исходники
COPY backend/ ./
# встраиваем собранную статику Vue
COPY --from=frontend /app/dist ./src/api/dist
# Миграции кладём туда, откуда Go их ищет
COPY backend/migrations ./migrations
# сборка бинарника
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./src/
# ================================
# Stage 3: финальный образ
# ================================
FROM scratch
COPY --from=backend /app/server /server
COPY --from=backend /app/migrations /migrations
COPY --from=backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=backend /usr/share/zoneinfo /usr/share/zoneinfo
ENTRYPOINT ["/server"]
-24
View File
@@ -1,24 +0,0 @@
version: '3.9'
services:
db:
build:
context: ..
dockerfile: infra/docker/postgres-pg-cron/Dockerfile
container_name: postgres
restart: always
command:
- postgres
- -c
- shared_preload_libraries=pg_cron
- -c
- cron.database_name=familyHubDB
environment:
POSTGRES_USER: familyUser
POSTGRES_PASSWORD: familyPass
POSTGRES_DB: familyHubDB
ports:
- "5432:5432"
volumes:
- ./volumes/postgres:/var/lib/postgresql/data
- ./docker/postgres-pg-cron/init:/docker-entrypoint-initdb.d
+55
View File
@@ -0,0 +1,55 @@
version: '3.9'
services:
app:
image: git.myhomecloud.tech/admin/familyhub:latest
container_name: application
restart: unless-stopped
ports:
- "8000:8000"
environment:
- DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_USER=${DB_USER}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME}
- BOT_TOKEN=${BOT_TOKEN}
- GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS}
- RUN_MODE=${RUN_MODE}
- API_SECRET=${API_SECRET}
- DB_PATH=postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable
- OPEN_API_ENABLED=${OPEN_API_ENABLED}
- DEBUG_MODE=${DEBUG_MODE}
depends_on:
- db
networks:
- family-hub-net
db:
image: git.myhomecloud.tech/admin/familyhub-postgres:latest
container_name: postgres
restart: always
pull_policy: always
ports:
- "5432:5432"
command:
- postgres
- -c
- shared_preload_libraries=pg_cron
- -c
- cron.database_name=familyHubDB
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d
networks:
- family-hub-net
networks:
family-hub-net:
volumes:
postgres-data:
-5
View File
@@ -1,5 +0,0 @@
FROM postgres:16
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-16-cron \
&& rm -rf /var/lib/apt/lists/*
+62
View File
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: application
namespace: family-hub
spec:
replicas: 1
selector:
matchLabels:
app: application
template:
metadata:
labels:
app: application
spec:
containers:
- name: application
image: git.myhomecloud.tech/admin/familyhub:latest
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: family-hub-config
- secretRef:
name: family-hub-secrets
env:
- name: GOOGLE_APPLICATION_CREDENTIALS
value: /secrets/credentials.json
volumeMounts:
- name: google-credentials
mountPath: /secrets
readOnly: true
# livenessProbe:
# httpGet:
# path: /api/v1/health
# port: 8000
# initialDelaySeconds: 10
# periodSeconds: 30
# readinessProbe:
# httpGet:
# path: /api/v1/health
# port: 8000
# initialDelaySeconds: 5
# periodSeconds: 10
volumes:
- name: google-credentials
secret:
secretName: google-credentials
imagePullSecrets:
- name: gitea-registry
---
apiVersion: v1
kind: Service
metadata:
name: application
namespace: family-hub
spec:
selector:
app: application
ports:
- port: 9876
targetPort: 8000
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: family-hub-config
namespace: family-hub
data:
DB_HOST: postgres
DB_PORT: "5432"
DB_NAME: familyHubDB
DB_USER: familyUser
API_PORT: "8000"
API_HOST: 0.0.0.0
RUN_MODE: standalone
OPEN_API_ENABLED: "true"
DEBUG_MODE: "false"
+19
View File
@@ -0,0 +1,19 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: application
namespace: family-hub
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
spec:
rules:
- host: application.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: application
port:
number: 9876
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: family-hub
+62
View File
@@ -0,0 +1,62 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: family-hub
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: git.myhomecloud.tech/admin/familyhub-postgres:latest
env:
- name: POSTGRES_USER
value: familyUser
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: family-hub-secrets
key: DB_PASSWORD
- name: POSTGRES_DB
value: familyHubDB
ports:
- containerPort: 5432
volumeMounts:
- name: postgres-data
mountPath: /var/lib/postgresql/data
imagePullSecrets:
- name: gitea-registry
volumes:
- name: postgres-data
persistentVolumeClaim:
claimName: postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: family-hub
spec:
selector:
app: postgres
ports:
- port: 5432
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: family-hub
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
+8
View File
@@ -0,0 +1,8 @@
FROM postgres:16
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-16-cron \
&& rm -rf /var/lib/apt/lists/* \
RUN echo "shared_preload_libraries = 'pg_cron'" >> /usr/share/postgresql/postgresql.conf.sample \
&& echo "cron.database_name = 'familyHubDB'" >> /usr/share/postgresql/postgresql.conf.sample
+32
View File
@@ -0,0 +1,32 @@
{
"id": 0,
"transaction_id": null,
"STATUS": 1,
"another_amount": 0,
"cash_amount": 0,
"cashbox_number": 119091676,
"cashier": "Старший кассир КСО",
"clearing_amount": 190.06,
"currency": "BYN",
"doc_num": "39972",
"house_to": "11",
"kod_soato": "5000000000",
"margin": 0,
"name_np": "Минск",
"name_spd": "Общество с ограниченной ответственностью \"ГРИНрозница\"",
"name_to": "Магазин \"ГРИН-5\"",
"oblast_soato": null,
"rayon_soato": null,
"selsovet_soato": null,
"payment_amount": 190.06,
"payment_type": 1,
"receipt_number": "CBB580D6268681CB071931DC",
"skno_number": "AVQ24170126963",
"street_to": "УЛ. ПЕТРА МСТИСЛАВЦА",
"success": "A check is correct",
"total_amount": 190.06,
"type_np": "г.",
"ui": "CBB580D6268681CB071931DC",
"unp": "191634233",
"issued_at": "09/11/2025, 18:08:31"
}