Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| baef5a0af2 | |||
| a4f9bb63aa | |||
| 8462b16305 |
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
responseBody, readErr := io.ReadAll(resp.Body)
|
||||||
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
log.Printf("failed to read external service error body: %v", readErr)
|
log.Printf("failed to read external service response body: %v", readErr)
|
||||||
|
return nil, readErr
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyText := strings.TrimSpace(string(responseBody))
|
bodyText := strings.TrimSpace(string(responseBody))
|
||||||
log.Printf("external service returned %d body=%q", resp.StatusCode, bodyText)
|
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 {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
25.8.2
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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 : []
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,115 +50,216 @@ 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' },
|
||||||
|
rent: { icon: Home, color: 'indigo' },
|
||||||
|
receipt: { icon: Receipt, color: 'blue' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const intlLocale = computed(() => (locale.value === 'ru' ? 'ru-RU' : 'en-US'));
|
||||||
|
const currencyFormatter = computed(() => new Intl.NumberFormat(intlLocale.value, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const transactionGroups = computed<TransactionGroup[]>(() => {
|
||||||
|
const groups = new Map<string, TransactionGroup>();
|
||||||
|
|
||||||
|
for (const transaction of transactions.value) {
|
||||||
|
const date = new Date(transaction.datetime);
|
||||||
|
const groupId = getGroupId(date);
|
||||||
|
const signedAmount = getSignedAmount(transaction);
|
||||||
|
|
||||||
|
if (!groups.has(groupId)) {
|
||||||
|
groups.set(groupId, {
|
||||||
|
id: groupId,
|
||||||
|
label: formatGroupLabel(date),
|
||||||
|
total: 0,
|
||||||
|
transactions: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
id: '2',
|
);
|
||||||
title: 'Uber Eats',
|
|
||||||
categoryKey: 'finance.category.foodDining',
|
|
||||||
amount: -45,
|
|
||||||
type: 'expense',
|
|
||||||
icon: Utensils,
|
|
||||||
color: 'orange',
|
|
||||||
time: '12:15 PM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
title: 'Starbucks',
|
|
||||||
categoryKey: 'finance.category.coffee',
|
|
||||||
amount: -12.5,
|
|
||||||
type: 'expense',
|
|
||||||
icon: Coffee,
|
|
||||||
color: 'amber',
|
|
||||||
time: '9:00 AM',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
title: 'Freelance Payment',
|
|
||||||
categoryKey: 'finance.category.income',
|
|
||||||
amount: 850,
|
|
||||||
type: 'income',
|
|
||||||
icon: TrendingUp,
|
|
||||||
color: 'blue',
|
|
||||||
time: '8:00 AM',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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
|
||||||
|
v-if="isLoading"
|
||||||
|
class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
|
||||||
|
>
|
||||||
|
{{ t('finance.transactions.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="errorMessage"
|
||||||
|
class="rounded-[16px] border border-rose-500/20 bg-rose-500/10 px-4 py-6 text-center text-[14px] text-rose-200"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="transactionGroups.length === 0"
|
||||||
|
class="rounded-[16px] border border-white/[0.06] bg-[#16161F] px-4 py-6 text-center text-[14px] text-zinc-400"
|
||||||
|
>
|
||||||
|
{{ t('finance.transactions.empty') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="group in transactionGroups" :key="group.id">
|
||||||
<div class="mb-3 flex items-center justify-between px-1">
|
<div class="mb-3 flex items-center justify-between px-1">
|
||||||
<h3 class="text-[14px] font-semibold text-white">{{ t(group.dateKey) }}</h3>
|
<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']">
|
<span :class="['text-[13px] font-semibold', group.total >= 0 ? 'text-emerald-400' : 'text-zinc-400']">
|
||||||
{{ group.total >= 0 ? '+' : '' }}${{ Math.abs(group.total).toFixed(2) }}
|
{{ group.total >= 0 ? '+' : '-' }}{{ formatAmount(group.total) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,7 +276,7 @@ const transactionGroups: TransactionGroup[] = [
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p>
|
<p class="mb-0.5 truncate text-[14px] font-semibold text-white">{{ transaction.title }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[12px] text-zinc-500">{{ t(transaction.categoryKey) }}</span>
|
<span class="text-[12px] text-zinc-500">{{ transaction.categoryLabel }}</span>
|
||||||
<span class="text-zinc-700">•</span>
|
<span class="text-zinc-700">•</span>
|
||||||
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span>
|
<span class="text-[12px] text-zinc-600">{{ transaction.time }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,11 +284,12 @@ const transactionGroups: TransactionGroup[] = [
|
|||||||
|
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']">
|
<p :class="['text-[15px] font-bold', transaction.type === 'income' ? 'text-emerald-400' : 'text-white']">
|
||||||
{{ transaction.type === 'income' ? '+' : '-' }}${{ Math.abs(transaction.amount).toFixed(2) }}
|
{{ transaction.type === 'income' ? '+' : '-' }}{{ formatAmount(transaction.amount) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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': 'Кофе',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user