fix integration
Build and Deploy / build-and-deploy (push) Successful in 19m19s

This commit is contained in:
2026-06-04 19:52:44 +03:00
parent 5f5c5de407
commit a9c931bd71
6 changed files with 132 additions and 39 deletions
+2
View File
@@ -348,6 +348,8 @@ func handleTransactionError(c *gin.Context, err error) {
switch { switch {
case errors.Is(err, services.ErrTransactionNotFound): case errors.Is(err, services.ErrTransactionNotFound):
c.JSON(http.StatusNotFound, dto.ErrorResponse{Message: err.Error()}) 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), case errors.Is(err, services.ErrTransactionPatch),
errors.Is(err, services.ErrReceiptLinkConflict), errors.Is(err, services.ErrReceiptLinkConflict),
errors.Is(err, services.ErrInvalidTransaction), errors.Is(err, services.ErrInvalidTransaction),
@@ -366,6 +366,32 @@ func TestTransactionsRouter_Create(t *testing.T) {
assert.Contains(t, w.Body.String(), "receipt not found") 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) { t.Run("returns 503 when receipt provider is unavailable in photo flow", func(t *testing.T) {
r := gin.New() r := gin.New()
apiV1 := r.Group("/api/v1") apiV1 := r.Group("/api/v1")
+5
View File
@@ -6,6 +6,7 @@ import (
"FamilyHub/src/repositories" "FamilyHub/src/repositories"
"FamilyHub/src/utils" "FamilyHub/src/utils"
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"strings" "strings"
@@ -47,6 +48,10 @@ func (s *receiptService) AddReceipt(
receiptID, err := s.repo.Create(ctx, receipt) receiptID, err := s.repo.Create(ctx, receipt)
if err != nil { 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)) log.Printf("receipt persist failed: err=%v receipt=%s", err, utils.ToLogJSON(receipt))
return nil, err return nil, err
} }
+1
View File
@@ -34,6 +34,7 @@ var (
ErrTransactionNotFound = errors.New("transaction not found") ErrTransactionNotFound = errors.New("transaction not found")
ErrTransactionPatch = errors.New("empty update payload") ErrTransactionPatch = errors.New("empty update payload")
ErrReceiptLinkConflict = errors.New("receipt_id and detach_receipt cannot be used together") 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") ErrInvalidTransaction = errors.New("type and category are required")
ErrReceiptNotFound = errors.New("receipt not found") ErrReceiptNotFound = errors.New("receipt not found")
ErrInvalidAnalytics = errors.New("type must be income or expense") ErrInvalidAnalytics = errors.New("type must be income or expense")
@@ -13,6 +13,8 @@ import (
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/cookiejar"
"regexp"
"strings" "strings"
"time" "time"
) )
@@ -44,8 +46,10 @@ type receiptProvider struct {
} }
func NewReceiptProvider() *receiptProvider { func NewReceiptProvider() *receiptProvider {
jar, _ := cookiejar.New(nil)
return &receiptProvider{ return &receiptProvider{
client: &http.Client{ client: &http.Client{
Jar: jar,
Timeout: 60 * time.Second, Timeout: 60 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
TLSClientConfig: &tls.Config{ 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( func (s *receiptProvider) GetReceipt(
ctx context.Context, ctx context.Context,
date, number string, date, number string,
) (*domain.Receipt, error) { ) (*domain.Receipt, error) {
url := "https://ch.info-center.by/ajax/check1.php"
var receipt domain.Receipt
body, contentType := buildMultipartBody(date, number) portalURL := "https://ch.info-center.by/"
requestBody := body.String()
log.Printf( // ===== 1. GET PAGE =====
"external request: service=receipt_provider method=%s url=%s content_type=%s body=%q", getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, portalURL, nil)
http.MethodPost, if err != nil {
url, return nil, err
contentType, }
utils.TruncateForLog(requestBody, utils.DefaultLogValueLimit),
) 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( httpReq, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodPost, http.MethodPost,
url, postUrl,
body, body,
) )
if err != nil { if err != nil {
log.Println(err.Error())
return nil, err return nil, err
} }
httpReq.Header.Set("Content-Type", contentType) 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 { if err != nil {
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) responseBody, err := io.ReadAll(resp.Body)
if readErr != nil { if err != nil {
log.Printf("failed to read external service response body: %v", readErr) return nil, err
return nil, readErr
} }
bodyText := strings.TrimSpace(string(responseBody)) 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 {
return nil, &ExternalServiceError{ return nil, &ExternalServiceError{
@@ -114,33 +155,33 @@ func (s *receiptProvider) GetReceipt(
} }
} }
// ===== PARSE =====
var raw struct { var raw struct {
Message map[string]interface{} `json:"message"` Message map[string]interface{} `json:"message"`
} }
if err := json.Unmarshal(responseBody, &raw); err != nil { if err := json.Unmarshal(responseBody, &raw); err != nil {
log.Printf("external service returned invalid json: %v", err)
return nil, err return nil, err
} }
bytes_, _ := json.Marshal(raw.Message) bytes_, _ := json.Marshal(raw.Message)
var receipt domain.Receipt
if err := json.Unmarshal(bytes_, &receipt); err != nil { if err := json.Unmarshal(bytes_, &receipt); err != nil {
return nil, err return nil, err
} }
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
} }
positions, err := parsePositions(receipt.PositionsRaw) positions, err := parsePositions(receipt.PositionsRaw)
if err != nil { if err != nil {
log.Printf("failed to parse positions: %s", err.Error())
return nil, err return nil, err
} }
receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw) receipt.IssuedAt, err = utils.ParseIssuedAt(receipt.IssuedAtRaw)
if err != nil { if err != nil {
log.Printf("failed to parse issued at: %s", err.Error())
return nil, err return nil, err
} }
@@ -151,13 +192,11 @@ func (s *receiptProvider) GetReceipt(
p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw) p.ProductCount, err = utils.ParseFloat(p.ProductCountRaw)
if err != nil { if err != nil {
log.Printf("failed to parse product count: %s", err.Error())
return nil, err return nil, err
} }
p.Amount, err = utils.ParseFloat(p.AmountRaw) p.Amount, err = utils.ParseFloat(p.AmountRaw)
if err != nil { if err != nil {
log.Printf("failed to parse amount: %s", err.Error())
return nil, err return nil, err
} }
@@ -168,7 +207,9 @@ func (s *receiptProvider) GetReceipt(
return &receipt, nil return &receipt, nil
} }
func buildMultipartBody(date, number string) (*bytes.Buffer, string) { // ===== MULTIPART =====
func buildMultipartBody(date, number, sessid string) (*bytes.Buffer, string) {
body := &bytes.Buffer{} body := &bytes.Buffer{}
writer := multipart.NewWriter(body) writer := multipart.NewWriter(body)
@@ -180,6 +221,9 @@ func buildMultipartBody(date, number string) (*bytes.Buffer, string) {
_ = writer.WriteField("orig_date", normalizedDate) _ = writer.WriteField("orig_date", normalizedDate)
_ = writer.WriteField("orig_ui", number) _ = writer.WriteField("orig_ui", number)
// 🔥 ВОТ ГЛАВНОЕ
_ = writer.WriteField("sessid", sessid)
_ = writer.Close() _ = writer.Close()
return body, writer.FormDataContentType() return body, writer.FormDataContentType()
+15
View File
@@ -7,8 +7,11 @@ import (
"log" "log"
"FamilyHub/src/domain" "FamilyHub/src/domain"
"github.com/lib/pq"
) )
var ErrReceiptAlreadyExists = errors.New("receipt already exists")
type ReceiptsRepository interface { type ReceiptsRepository interface {
Create(ctx context.Context, receipt *domain.Receipt) (int64, error) Create(ctx context.Context, receipt *domain.Receipt) (int64, error)
GetByID(ctx context.Context, id int64) (*domain.Receipt, 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) err = tx.QueryRowContext(ctx, query, args...).Scan(&receiptID)
if err != nil { if err != nil {
if isReceiptAlreadyExistsError(err) {
return 0, ErrReceiptAlreadyExists
}
return 0, err return 0, err
} }
@@ -185,6 +191,15 @@ func (r *ReceiptsSQLRepository) Create(ctx context.Context, receipt *domain.Rece
return receiptID, nil 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) { func (r *ReceiptsSQLRepository) GetByID(ctx context.Context, id int64) (*domain.Receipt, error) {
var receipt domain.Receipt var receipt domain.Receipt