5 Commits

11 changed files with 207 additions and 41 deletions
+6 -1
View File
@@ -57,7 +57,12 @@ jobs:
aarch64) KUBECTL_ARCH="arm64" ;;
armv7l) KUBECTL_ARCH="arm" ;;
esac
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/${KUBECTL_ARCH}/kubectl"
VERSION=$(curl --http1.1 -Ls https://dl.k8s.io/release/stable.txt)
curl --http1.1 -LO \
"https://dl.k8s.io/release/${VERSION}/bin/linux/${KUBECTL_ARCH}/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
+2
View File
@@ -348,6 +348,8 @@ func handleTransactionError(c *gin.Context, err error) {
switch {
case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrReceiptAlreadyExists):
c.JSON(http.StatusConflict, dto.ErrorResponse{Message: err.Error()})
case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction),
@@ -366,6 +366,32 @@ func TestTransactionsRouter_Create(t *testing.T) {
assert.Contains(t, w.Body.String(), "receipt not found")
})
t.Run("returns 409 when receipt already exists in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
ocrSvc := &ocrMock{recognizeFn: func(ctx context.Context, image []byte) (string, error) {
return "21.01.2026 " + validNumber, nil
}}
receiptSvc := &receiptServiceMock{getReceiptFn: func(ctx context.Context, req domain.AddReceiptRequest) (*domain.Receipt, error) {
return nil, services.ErrReceiptAlreadyExists
}}
creationService := services.NewTransactionCreationService(&transactionServiceMock{}, receiptSvc, ocrSvc)
router := NewTransactionsRouter(&transactionServiceMock{}, creationService)
router.RegisterRoutes(apiV1)
req := newMultipartRequest(t, map[string]string{
"family_id": "1",
"created_by": "2",
})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusConflict, w.Code)
assert.Contains(t, w.Body.String(), "receipt already exists")
})
t.Run("returns 503 when receipt provider is unavailable in photo flow", func(t *testing.T) {
r := gin.New()
apiV1 := r.Group("/api/v1")
+5
View File
@@ -6,6 +6,7 @@ import (
"FamilyHub/src/repositories"
"FamilyHub/src/utils"
"context"
"errors"
"fmt"
"log"
"strings"
@@ -47,6 +48,10 @@ func (s *receiptService) AddReceipt(
receiptID, err := s.repo.Create(ctx, receipt)
if err != nil {
if errors.Is(err, repositories.ErrReceiptAlreadyExists) {
log.Printf("receipt persist failed: err=%v receipt=%s", ErrReceiptAlreadyExists, utils.ToLogJSON(receipt))
return nil, ErrReceiptAlreadyExists
}
log.Printf("receipt persist failed: err=%v receipt=%s", err, utils.ToLogJSON(receipt))
return nil, err
}
+23
View File
@@ -0,0 +1,23 @@
package services
import (
"FamilyHub/src/domain"
"testing"
)
func TestBuildReceiptTransactionDescription_UsesNameTO(t *testing.T) {
receipt := &domain.Receipt{
NameSPD: "Old merchant name",
NameTO: "Expected terminal name",
ReceiptNumber: "77F05E82C1ED044B07194794",
}
description := buildReceiptTransactionDescription(receipt, nil)
if description == nil {
t.Fatal("expected description to be set")
}
if *description != "Expected terminal name" {
t.Fatalf("expected description %q, got %q", "Expected terminal name", *description)
}
}
+7 -6
View File
@@ -31,12 +31,13 @@ func NewTransactionService(repo repositories.TransactionRepository, activityRepo
}
var (
ErrTransactionNotFound = errors.New("transaction not found")
ErrTransactionPatch = errors.New("empty update payload")
ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together")
ErrInvalidTransaction = errors.New("type and category are required")
ErrReceiptNotFound = errors.New("receipt not found")
ErrInvalidAnalytics = errors.New("type must be income or expense")
ErrTransactionNotFound = errors.New("transaction not found")
ErrTransactionPatch = errors.New("empty update payload")
ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together")
ErrReceiptAlreadyExists = errors.New("receipt already exists")
ErrInvalidTransaction = errors.New("type and category are required")
ErrReceiptNotFound = errors.New("receipt not found")
ErrInvalidAnalytics = errors.New("type must be income or expense")
)
func (s *transactionService) Create(ctx context.Context, req domain.CreateTransactionRequest) (*domain.Transaction, error) {
@@ -13,6 +13,8 @@ import (
"log"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"regexp"
"strings"
"time"
)
@@ -44,8 +46,10 @@ type receiptProvider struct {
}
func NewReceiptProvider() *receiptProvider {
jar, _ := cookiejar.New(nil)
return &receiptProvider{
client: &http.Client{
Jar: jar,
Timeout: 60 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
@@ -56,56 +60,93 @@ func NewReceiptProvider() *receiptProvider {
}
}
// ===== SESSID EXTRACT =====
var sessidRe = regexp.MustCompile(`"bitrix_sessid"\s*:\s*"([a-f0-9]+)"`)
func extractSessID(html string) (string, error) {
matches := sessidRe.FindStringSubmatch(html)
if len(matches) < 2 {
return "", errors.New("bitrix_sessid not found")
}
return matches[1], nil
}
// ===== MAIN =====
func (s *receiptProvider) GetReceipt(
ctx context.Context,
date, number string,
) (*domain.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php"
var receipt domain.Receipt
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),
)
portalURL := "https://ch.info-center.by/"
// ===== 1. GET PAGE =====
getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, portalURL, nil)
if err != nil {
return nil, err
}
getReq.Header.Set("User-Agent",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36")
resp, err := s.client.Do(getReq)
if err != nil {
return nil, err
}
htmlBytes, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
html := string(htmlBytes)
// ===== 2. EXTRACT SESSID =====
sessid, err := extractSessID(html)
if err != nil {
log.Printf("sessid extract error: %v", err)
return nil, err
}
log.Printf("sessid: %s", sessid)
// ===== 3. POST REQUEST =====
time.Sleep(300 * time.Millisecond)
postUrl := "https://ch.info-center.by/ajax/check1.php"
body, contentType := buildMultipartBody(date, number, sessid)
httpReq, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
url,
postUrl,
body,
)
if err != nil {
log.Println(err.Error())
return nil, err
}
httpReq.Header.Set("Content-Type", contentType)
httpReq.Header.Set("Accept", "*/*")
httpReq.Header.Set("X-Requested-With", "XMLHttpRequest")
httpReq.Header.Set("Origin", "https://ch.info-center.by")
httpReq.Header.Set("Referer", "https://ch.info-center.by/")
resp, err := s.client.Do(httpReq)
resp, err = s.client.Do(httpReq)
if err != nil {
log.Printf("external response: service=receipt_provider method=%s url=%s err=%v", http.MethodPost, url, err)
return nil, err
}
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
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
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 {
return nil, &ExternalServiceError{
@@ -114,33 +155,33 @@ func (s *receiptProvider) GetReceipt(
}
}
// ===== PARSE =====
var raw struct {
Message map[string]interface{} `json:"message"`
}
if err := json.Unmarshal(responseBody, &raw); err != nil {
log.Printf("external service returned invalid json: %v", err)
return nil, err
}
bytes_, _ := json.Marshal(raw.Message)
var receipt domain.Receipt
if err := json.Unmarshal(bytes_, &receipt); err != nil {
return nil, err
}
if receipt.IssuedAtRaw == "" {
log.Printf("external response parse failed: service=receipt_provider err=%v date=%s number=%s", ErrReceiptNotFound, date, number)
return nil, ErrReceiptNotFound
}
positions, err := parsePositions(receipt.PositionsRaw)
if err != nil {
log.Printf("failed to parse positions: %s", err.Error())
return nil, err
}
receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw)
if err != nil {
log.Printf("failed to parse issued at: %s", err.Error())
return nil, err
}
@@ -151,13 +192,11 @@ func (s *receiptProvider) GetReceipt(
p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw)
if err != nil {
log.Printf("failed to parse product count: %s", err.Error())
return nil, err
}
p.Amount, err = utils.ParseFloat(p.AmountRaw)
if err != nil {
log.Printf("failed to parse amount: %s", err.Error())
return nil, err
}
@@ -168,7 +207,9 @@ func (s *receiptProvider) GetReceipt(
return &receipt, nil
}
func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
// ===== MULTIPART =====
func buildMultipartBody(date, number, sessid string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
@@ -180,6 +221,9 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
_ = writer.WriteField("orig_date", normalizedDate)
_ = writer.WriteField("orig_ui", number)
// 🔥 ВОТ ГЛАВНОЕ
_ = writer.WriteField("sessid", sessid)
_ = writer.Close()
return body, writer.FormDataContentType()
@@ -0,0 +1,19 @@
package receiptProvider
import (
"strings"
"testing"
)
func TestBuildMultipartBody_NormalizesDateToISO(t *testing.T) {
body, _ := buildMultipartBody("07.04.2026", "77F05E82C1ED044B07194794")
payload := body.String()
if !strings.Contains(payload, "2026-04-07") {
t.Fatalf("expected ISO date in multipart body, got %q", payload)
}
if strings.Contains(payload, "07.04.2026") {
t.Fatalf("did not expect source date format in multipart body, got %q", payload)
}
}
+15
View File
@@ -7,8 +7,11 @@ import (
"log"
"FamilyHub/src/domain"
"github.com/lib/pq"
)
var ErrReceiptAlreadyExists = errors.New("receipt already exists")
type ReceiptsRepository interface {
Create(ctx context.Context, receipt *domain.Receipt) (int64, error)
GetByID(ctx context.Context, id int64) (*domain.Receipt, error)
@@ -129,6 +132,9 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
err = tx.QueryRowContext(ctx, query, args...).Scan(&receiptID)
if err != nil {
if isReceiptAlreadyExistsError(err) {
return 0, ErrReceiptAlreadyExists
}
return 0, err
}
@@ -185,6 +191,15 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
return receiptID, nil
}
func isReceiptAlreadyExistsError(err error) bool {
var pqErr *pq.Error
if !errors.As(err, &pqErr) {
return false
}
return pqErr.Code == "23505" && pqErr.Constraint == "receipts_receipt_number_key"
}
func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) {
var receipt domain.Receipt
+25
View File
@@ -0,0 +1,25 @@
package utils
import "testing"
func TestExtractReceiptMeta_NormalizesOCRDateWithColon(t *testing.T) {
text := "Двойной Американо\n1762992079489\n1,000 x 5,50\n5,50\nИТОГО К ОПЛАТЕ:\n5,50\nБП Карта:\n5,50\nКассир:\nср Кто все эти л\nДата и время:\n07:04.2026 08:57:14\nУИ:\n77F05E82C1ED044B07194794"
meta := ExtractReceiptMeta(text)
if meta.Date != "07.04.2026" {
t.Fatalf("expected normalized date %q, got %q", "07.04.2026", meta.Date)
}
if meta.ReceiptID != "77F05E82C1ED044B07194794" {
t.Fatalf("expected receipt id %q, got %q", "77F05E82C1ED044B07194794", meta.ReceiptID)
}
}
func TestExtractReceiptMeta_DoesNotTreatTimeAsDate(t *testing.T) {
meta := ExtractReceiptMeta("Дата и время:\n08:57:14\nУИ:\n77F05E82C1ED044B07194794")
if meta.Date != "" {
t.Fatalf("expected empty date, got %q", meta.Date)
}
}
+2 -1
View File
@@ -39,7 +39,8 @@ FROM scratch
COPY --from=backend /app/server /server
COPY --from=backend /app/migrations /migrations
COPY --from=backend /app/src/api/dist /src/api/dist
COPY --from=backend /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=backend /usr/share/zoneinfo /usr/share/zoneinfo
ENTRYPOINT ["/server"]
ENTRYPOINT ["/server"]