Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c931bd71 | |||
| 5f5c5de407 | |||
| 2cc1245d12 |
@@ -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/
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ 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")
|
||||
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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user