Compare commits

..

28 Commits

Author SHA1 Message Date
a5c9403576 Add transaction adjustment on cancellation (when amount differs)
All checks were successful
build docker image / docker-build (push) Successful in 2m16s
2025-06-06 16:54:36 +03:00
ebb213a3cc Add transaction adjustment on cancellation (when amount differs)
Some checks failed
build docker image / docker-build (push) Has been cancelled
2025-06-06 16:54:16 +03:00
f947606131 Update .gitea/workflows/build-docker-image.yaml
All checks were successful
build docker image / docker-build (push) Successful in 1m4s
2025-01-04 21:47:29 +02:00
464093e5bd Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 53s
2024-11-15 13:29:14 +02:00
f96f88d16e Add docker deploy
Some checks failed
build docker image / docker-build (push) Has been cancelled
2024-11-15 13:28:44 +02:00
686c46bf78 Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 1m32s
2024-11-04 21:58:44 +02:00
38b4e89a02 Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 1m32s
2024-11-04 20:39:15 +02:00
3986e1c9de Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 49s
2024-11-04 20:18:28 +02:00
f0e26a0cd2 Add docker deploy
Some checks failed
build docker image / docker-build (push) Has been cancelled
2024-11-04 20:18:18 +02:00
00657a8660 Add docker deploy
Some checks failed
build docker image / docker-build (push) Has been cancelled
2024-11-04 20:18:07 +02:00
39459202f1 Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 1m14s
2024-11-04 20:14:17 +02:00
896e91e61e Add docker deploy 2024-11-04 20:14:10 +02:00
950d6649b2 Add docker deploy
Some checks failed
build docker image / docker-build (push) Has been cancelled
2024-11-04 20:14:03 +02:00
a3e2a462a3 Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 46s
2024-11-04 20:12:59 +02:00
34dce58d91 Add docker deploy
Some checks failed
build docker image / docker-build (push) Failing after 47s
2024-11-04 20:11:50 +02:00
de69376bff Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 48s
2024-11-04 20:10:27 +02:00
e0d4758540 Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 48s
2024-11-04 20:07:38 +02:00
efb5bf0b3b Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 47s
2024-11-04 20:05:44 +02:00
daf5f8fb03 Add docker deploy
All checks were successful
build docker image / docker-build (push) Successful in 47s
2024-11-04 19:54:06 +02:00
b05b1b9b48 Remove load .env from bin path
Some checks failed
build docker image / docker-build (push) Failing after 51s
2024-11-04 19:44:42 +02:00
074c32a8f1 Remove load .env from bin path 2024-10-08 16:58:38 +03:00
917d6d9103 Remove load .env from bin path 2024-10-08 16:58:13 +03:00
540eef981f Loan .env from bin path 2024-10-08 16:04:54 +03:00
7c03492676 Update dependencies 2024-09-25 19:57:44 +03:00
cd31de87e5 Fix webhook transactions never been logging 2024-09-07 11:38:02 +03:00
25212fbd1c Fix webhook transactions never been logging 2024-08-29 16:03:08 +03:00
fb7796c475 Add duplicate transaction check from log and transaction description LIKE match 2024-08-28 22:41:05 +03:00
e1c9f56921 Fix invalid check for transferring between accounts with different currencies 2024-08-28 22:40:12 +03:00
10 changed files with 205 additions and 38 deletions

View File

@ -0,0 +1,34 @@
name: build docker image
on:
- push
jobs:
docker-build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: gitea.stuzer.link
username: ${{ gitea.repository_owner }}
password: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
push: true
tags: gitea.stuzer.link/stuzer05/monobank-firefly3-bot:latest
cache-from: type=registry,ref=gitea.stuzer.link/stuzer05/monobank-firefly3-bot:latest
cache-to: type=inline

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM golang:1.23.2 AS builder
# Install certificates
RUN apt-get update && apt-get install -y ca-certificates
WORKDIR /app
COPY go.mod go.sum .
RUN go mod download && go mod verify
# Copy project files
COPY . .
# Build
RUN make
FROM scratch
COPY --from=builder /app/monobank-firefly3-bot /app
ENTRYPOINT ["/app"]

View File

@ -12,6 +12,7 @@ import (
"os"
"slices"
"strconv"
"strings"
"time"
)
@ -54,7 +55,7 @@ func ImportTransaction(monobankTransaction monobank.WebHookResponse) error {
return err
}
// find matching transaction to delete
// find matching transaction to adjust/delete
isDeleted := false
for _, tRows := range oldTransactions.Data {
if isDeleted {
@ -69,31 +70,74 @@ func ImportTransaction(monobankTransaction monobank.WebHookResponse) error {
}
// read monobank transaction
var monobankTransaction monobank.WebHookResponse
err = json.Unmarshal(notesBytes, &monobankTransaction)
var monobankTransactionOld monobank.WebHookResponse
err = json.Unmarshal(notesBytes, &monobankTransactionOld)
if err != nil {
continue
}
// Parse amounts
sumNew := int64(math.Abs(math.Round(monobankTransaction.Data.StatementItem.Amount/100))) - int64(math.Abs(math.Round(monobankTransaction.Data.StatementItem.CommissionRate/100)))
sumOldFloat, _ := strconv.ParseFloat(tRow.Amount, 64)
sumOld := int64(sumOldFloat)
// find transaction
sum := int(math.Abs(math.Round(monobankTransaction.Data.StatementItem.Amount/100))) - int(math.Abs(math.Round(monobankTransaction.Data.StatementItem.CommissionRate/100)))
sum2, _ := strconv.ParseFloat(tRow.Amount, 64)
if slices.Contains(row.Names, monobankTransaction.Data.StatementItem.Description) && sum == int(sum2) {
if slices.Contains(row.Names, monobankTransactionOld.Data.StatementItem.Description) {
if sumNew == sumOld {
// delete transaction
opts := firefly3.TransactionsApiDeleteTransactionOpts{}
_, err := App().Firefly3Client.TransactionsApi.DeleteTransaction(context.Background(), tRows.Id, &opts)
if err != nil {
return err
}
} else {
// adjust transaction
opts := firefly3.TransactionsApiUpdateTransactionOpts{}
body := firefly3.TransactionUpdate{
Transactions: []firefly3.TransactionSplitUpdate{
{
Description: tRow.Description,
CategoryId: tRow.CategoryId,
DestinationId: tRow.DestinationId,
SourceId: tRow.SourceId,
CurrencyId: tRow.CurrencyId,
ExternalUrl: tRow.ExternalUrl,
Date: tRow.Date,
DueDate: tRow.DueDate,
Tags: tRow.Tags,
Notes: tRow.Notes,
// Notes: string(monobankTransactionJson),
Amount: strconv.FormatInt(sumOld-sumNew, 10),
},
},
}
_, _, err := App().Firefly3Client.TransactionsApi.UpdateTransaction(context.Background(), body, tRows.Id, &opts)
if err != nil {
return err
}
}
isDeleted = true
isDeleted = true // break 2
}
}
}
break
} else {
// check name match
isDescriptionMatch := false
if row.NamesLooseMatch {
for _, name := range row.Names {
if strings.HasPrefix(monobankTransaction.Data.StatementItem.Description, name) {
isDescriptionMatch = true
break
}
}
} else {
isDescriptionMatch = slices.Contains(row.Names, monobankTransaction.Data.StatementItem.Description)
}
// check name & mcc
if !(slices.Contains(row.Names, monobankTransaction.Data.StatementItem.Description) || slices.Contains(row.MccCodes, int(monobankTransaction.Data.StatementItem.Mcc))) {
if !(isDescriptionMatch || slices.Contains(row.MccCodes, int(monobankTransaction.Data.StatementItem.Mcc))) {
continue
}
@ -135,7 +179,7 @@ func ImportTransaction(monobankTransaction monobank.WebHookResponse) error {
// when transfer between different currencies, convert
sourceAccount := App().Config.GetAccountByFirefly3Name(firefly3Transaction.SourceName)
if sourceAccount.Currency != destAccount.Currency {
if len(sourceAccount.Currency) > 0 && sourceAccount.Currency != destAccount.Currency {
// swap amounts
firefly3Transaction.ForeignAmount = firefly3Transaction.Amount
firefly3Transaction.Amount = strconv.Itoa(int(math.Abs(math.Round(monobankTransaction.Data.StatementItem.OperationAmount / 100))))

View File

@ -1,6 +1,8 @@
package app
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
@ -20,3 +22,49 @@ func LogString(str string) {
fmt.Println(err)
}
}
func LogContainsTransactionID(transactionID string) (bool, error) {
if len(os.Getenv("LOG_FILE")) == 0 {
return false, nil
}
// open the log file for reading.
logFile, err := os.Open(os.Getenv("LOG_FILE"))
if err != nil {
return false, fmt.Errorf("error opening log file: %w", err)
}
defer logFile.Close()
// create a new scanner to read the log file line by line.
scanner := bufio.NewScanner(logFile)
// iterate over each line of the log file.
for scanner.Scan() {
// unmarshal the JSON data from the current line.
var transactionData struct {
Data struct {
StatementItem struct {
ID string `json:"id"`
} `json:"statementItem"`
} `json:"data"`
}
err := json.Unmarshal(scanner.Bytes(), &transactionData)
if err != nil {
// skip lines that are not valid JSON.
continue
}
// check if the transaction ID matches the given ID.
if transactionData.Data.StatementItem.ID == transactionID {
return true, nil
}
}
// check for any errors that occurred during scanning.
if err := scanner.Err(); err != nil {
return false, fmt.Errorf("error scanning log file: %w", err)
}
// transaction ID not found in the log file.
return false, nil
}

View File

@ -1,25 +1,26 @@
package config
type Config struct {
Accounts []ConfigAccount `json:"accounts"`
TransactionTypes []ConfigTransactionTypes `json:"transaction_types"`
Accounts []Account `json:"accounts"`
TransactionTypes []TransactionTypes `json:"transaction_types"`
}
type ConfigAccount struct {
type Account struct {
Firefly3Name string `json:"firefly3_name,omitempty"`
MonobankId string `json:"monobank_id,omitempty"`
Currency string `json:"currency,omitempty"`
}
type ConfigTransactionTypes struct {
type TransactionTypes struct {
Names []string `json:"names,omitempty"`
NamesRefund []string `json:"names_refund,omitempty"`
NamesLooseMatch bool `json:"names_loose_match,omitempty"` // "name%" match
MccCodes []int `json:"mcc_codes,omitempty"`
Firefly3 ConfigTransactionTypeFirefly3 `json:"firefly3,omitempty"`
Firefly3 TransactionTypeFirefly3 `json:"firefly3,omitempty"`
SumMax int `json:"sum_max,omitempty"`
}
type ConfigTransactionTypeFirefly3 struct {
type TransactionTypeFirefly3 struct {
Type string `json:"type,omitempty"`
Destination string `json:"destination,omitempty"`
Description string `json:"description,omitempty"`

View File

@ -1,21 +1,21 @@
package config
func (c *Config) GetAccountByMonobankId(q string) ConfigAccount {
func (c *Config) GetAccountByMonobankId(q string) Account {
for _, row := range c.Accounts {
if row.MonobankId == q {
return row
}
}
return ConfigAccount{}
return Account{}
}
func (c *Config) GetAccountByFirefly3Name(q string) ConfigAccount {
func (c *Config) GetAccountByFirefly3Name(q string) Account {
for _, row := range c.Accounts {
if row.Firefly3Name == q {
return row
}
}
return ConfigAccount{}
return Account{}
}

5
go.mod
View File

@ -4,11 +4,12 @@ go 1.23
require (
gitea.stuzer.link/stuzer05/go-firefly3/v2 v2.1.0
gitea.stuzer.link/stuzer05/go-monobank v0.2303.0
gitea.stuzer.link/stuzer05/go-monobank v0.2303.1
github.com/antihax/optional v1.0.0
github.com/joho/godotenv v1.5.1
)
require (
golang.org/x/oauth2 v0.22.0 // indirect
github.com/sanity-io/litter v1.5.5 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
)

4
go.sum
View File

@ -2,6 +2,8 @@ gitea.stuzer.link/stuzer05/go-firefly3/v2 v2.1.0 h1:t+FOFg48PPN2n6SO6PwbqVpHGqYK
gitea.stuzer.link/stuzer05/go-firefly3/v2 v2.1.0/go.mod h1:FNdERhJjtqfkBQqR2EvB7T3h6eGJVid4xcBCUu1/9FU=
gitea.stuzer.link/stuzer05/go-monobank v0.2303.0 h1:BDv3h9bk5Fs3iI51W+pXmjPBAQn9T+YZu+B/lmbeBqc=
gitea.stuzer.link/stuzer05/go-monobank v0.2303.0/go.mod h1:y/jOBU1U+NNR5umvDH+scrOWk0byZGZrMb4PHEehyrk=
gitea.stuzer.link/stuzer05/go-monobank v0.2303.1 h1:2IdaL70SVyb3aUqlC6TNNi9VOyliYDdiqKSzhCwHmaE=
gitea.stuzer.link/stuzer05/go-monobank v0.2303.1/go.mod h1:sZvm8Jhtwpup3/X0acYuT0b+v+r4X0SnCBrJxQjlqkw=
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -19,3 +21,5 @@ golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=

11
main.go
View File

@ -17,10 +17,7 @@ import (
func main() {
// load .env
err := godotenv.Load(".env")
if err != nil {
log.Fatalf("error loading .env file")
}
godotenv.Load(".env")
// init app
app.Init()
@ -63,13 +60,13 @@ func main() {
}
} else if len(*flagMonobankDoTransaction) > 0 {
var monobankTransaction monobank.WebHookResponse
err = json.Unmarshal([]byte(*flagMonobankDoTransaction), &monobankTransaction)
err := json.Unmarshal([]byte(*flagMonobankDoTransaction), &monobankTransaction)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
err := app.ImportTransaction(monobankTransaction)
err = app.ImportTransaction(monobankTransaction)
if err != nil {
fmt.Println(err.Error())
os.Exit(1)
@ -81,7 +78,7 @@ func main() {
// register monobank webhook
_, err := app.App().MonobankClient.Api.PersonalWebhookPost(context.Background(), monobank.SetWebHook{WebHookUrl: webhookUrl}, os.Getenv("MONOBANK_TOKEN"))
if err != nil {
log.Fatalln("failed to register monobank webhook")
log.Fatalln("failed to register monobank webhook: " + err.Error())
}
// set webhook

View File

@ -9,6 +9,10 @@ import (
)
func handleWebhook(w http.ResponseWriter, r *http.Request) {
// Parse URL query parameters
queryParams := r.URL.Query()
isRetry := queryParams.Get("retry") == "true"
// read request body bytes
body, err := io.ReadAll(r.Body)
if err != nil {
@ -18,7 +22,7 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) {
}
// log body string
app.LogString(string(body))
defer app.LogString(string(body))
// check request empty body
if len(string(body)) == 0 {
@ -36,6 +40,18 @@ func handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// only check for logged transaction if not a retry
if !isRetry {
isTransactionAlreadyLogged, err := app.LogContainsTransactionID(monobankTransaction.Data.StatementItem.Id)
if err != nil {
app.LogString(err.Error())
return
}
if isTransactionAlreadyLogged {
return
}
}
err = app.ImportTransaction(monobankTransaction)
if err != nil {
app.LogString(err.Error())