diff --git a/backend/src/api/routers/transactions.go b/backend/src/api/routers/transactions.go index 905b33a..2db1501 100644 --- a/backend/src/api/routers/transactions.go +++ b/backend/src/api/routers/transactions.go @@ -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), diff --git a/backend/src/api/routers/transactions_test.go b/backend/src/api/routers/transactions_test.go index fff31d4..0cfb59e 100644 --- a/backend/src/api/routers/transactions_test.go +++ b/backend/src/api/routers/transactions_test.go @@ -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") diff --git a/backend/src/api/services/receipts.go b/backend/src/api/services/receipts.go index bf542a8..4265d23 100644 --- a/backend/src/api/services/receipts.go +++ b/backend/src/api/services/receipts.go @@ -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 } diff --git a/backend/src/api/services/transactions.go b/backend/src/api/services/transactions.go index d3c00bd..c97289c 100644 --- a/backend/src/api/services/transactions.go +++ b/backend/src/api/services/transactions.go @@ -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) { diff --git a/backend/src/integrations/receiptProvider/receipt_provider.go b/backend/src/integrations/receiptProvider/receipt_provider.go index 4f10eb2..fd20070 100644 --- a/backend/src/integrations/receiptProvider/receipt_provider.go +++ b/backend/src/integrations/receiptProvider/receipt_provider.go @@ -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() diff --git a/backend/src/repositories/receipts.go b/backend/src/repositories/receipts.go index ae52a1b..7bfc602 100644 --- a/backend/src/repositories/receipts.go +++ b/backend/src/repositories/receipts.go @@ -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