Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9c931bd71 | |||
| 5f5c5de407 | |||
| 2cc1245d12 |
@@ -57,7 +57,12 @@ jobs:
|
|||||||
aarch64) KUBECTL_ARCH="arm64" ;;
|
aarch64) KUBECTL_ARCH="arm64" ;;
|
||||||
armv7l) KUBECTL_ARCH="arm" ;;
|
armv7l) KUBECTL_ARCH="arm" ;;
|
||||||
esac
|
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
|
chmod +x kubectl
|
||||||
sudo mv kubectl /usr/local/bin/
|
sudo mv kubectl /usr/local/bin/
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user