diff options
| author | Patrick Spek <p.spek@tyil.nl> | 2024-12-07 10:04:02 +0100 |
|---|---|---|
| committer | Patrick Spek <p.spek@tyil.nl> | 2024-12-07 10:04:02 +0100 |
| commit | 876a930835aa633ebf4ef3a7499cb54c9736b0fd (patch) | |
| tree | a6980e242304c38790b265d28f76201e7b15dd59 | |
| download | firefly-importer-876a930835aa633ebf4ef3a7499cb54c9736b0fd.tar.gz firefly-importer-876a930835aa633ebf4ef3a7499cb54c9736b0fd.tar.bz2 | |
Initial commit
| -rw-r--r-- | api.go | 187 | ||||
| -rw-r--r-- | camt053.go | 198 | ||||
| -rw-r--r-- | firefly_iii.go | 65 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | main.go | 154 |
6 files changed, 611 insertions, 0 deletions
@@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/url" +) + +// Get the name of an account by its associated IBAN. +func fireflyApiAccountGetNameByIban(baseUrl string, pat string, iban string) (string, error) { + endpoint := baseUrl + "/api/v1/search/accounts" + req, err := http.NewRequest("GET", endpoint+"?field=iban&query="+url.QueryEscape(iban), nil) + if err != nil { + return "", err + } + + // Set headers + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+pat) + + slog.Debug("Checking IBAN", "endpoint", endpoint, "pat", pat, "iban", url.QueryEscape(iban)) + + httpClient := http.Client{} + res, err := httpClient.Do(req) + if err != nil { + return "", err + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + slog.Debug("API response", "code", res.StatusCode, "payload", resBody) + + if res.StatusCode != 200 { + return "", errors.New("Received HTTP " + res.Status) + } + + accSearch := FireflyAccountSearch{} + err = json.Unmarshal(resBody, &accSearch) + if err != nil { + return "", err + } + + for _, acc := range accSearch.Data { + if iban == acc.Attributes.Iban { + return acc.Attributes.Name, nil + } + } + + return "", nil +} + +// Get the type of account by its name. +func fireflyApiAccountGetTypeByName(baseUrl string, pat string, account string) (string, error) { + endpoint := baseUrl + "/api/v1/search/accounts" + req, err := http.NewRequest("GET", endpoint+"?field=name&query="+url.QueryEscape(account), nil) + if err != nil { + return "", err + } + + // Set headers + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+pat) + + slog.Debug("Checking account", "endpoint", endpoint, "pat", pat, "account", url.QueryEscape(account)) + + httpClient := http.Client{} + res, err := httpClient.Do(req) + if err != nil { + return "", err + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return "", err + } + + slog.Debug("API response", "code", res.StatusCode, "payload", resBody) + + if res.StatusCode != 200 { + return "", errors.New("Received HTTP " + res.Status) + } + + accSearch := FireflyAccountSearch{} + err = json.Unmarshal(resBody, &accSearch) + if err != nil { + return "", err + } + + for _, acc := range accSearch.Data { + if account == acc.Attributes.Name { + return acc.Attributes.Type, nil + } + } + + return "", nil +} + +// Add a new transaction to Firefly III. +func fireflyApiTransactionAdd(baseUrl string, pat string, tx FireflyTransaction) error { + endpoint := baseUrl + "/api/v1/transactions" + body, err := json.Marshal(FireflyTransactionPost{ + ApplyRules: true, + FireWebhooks: true, + Transactions: []FireflyTransaction{tx}, + }) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(body)) + if err != nil { + return err + } + + // Set headers + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+pat) + req.Header.Set("Content-Type", "application/json") + + slog.Debug("Posting transaction", "endpoint", endpoint, "pat", pat, "payload", body) + + httpClient := http.Client{} + res, err := httpClient.Do(req) + if err != nil { + return err + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + slog.Debug("API response", "code", res.StatusCode, "payload", resBody) + + if res.StatusCode != 200 { + return errors.New("Received HTTP " + res.Status) + } + + return nil +} + +// Check if a transaction for a given external_id already exists. +func fireflyApiTransactionExistsByExternalId(baseUrl string, pat string, id string) (bool, error) { + endpoint := baseUrl + "/api/v1/search/transactions" + req, err := http.NewRequest("GET", endpoint+"?query=external_id_is:"+id, nil) + if err != nil { + return false, err + } + + // Set headers + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+pat) + + slog.Debug("Checking transaction", "endpoint", endpoint, "pat", pat, "id", id) + + httpClient := http.Client{} + res, err := httpClient.Do(req) + if err != nil { + return false, err + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return false, err + } + + slog.Debug("API response", "code", res.StatusCode, "payload", resBody) + + if res.StatusCode != 200 { + return false, errors.New("Received HTTP " + res.Status) + } + + txSearch := FireflyTransactionSearch{} + err = json.Unmarshal(resBody, &txSearch) + if err != nil { + return false, err + } + + return (0 < txSearch.Meta.Pagination.Count), nil +} diff --git a/camt053.go b/camt053.go new file mode 100644 index 0000000..f30b8fb --- /dev/null +++ b/camt053.go @@ -0,0 +1,198 @@ +package main + +import ( + "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type Camt053 struct { + Document string `xml:"Document"` + XmlNs string `xml:"xmlns,attr"` + BkToCstmrStmt Camt053BkToCstmrStmt `xml:"BkToCstmrStmt"` +} + +type Camt053Acct struct { + Id Camt053Id `xml:"Id"` +} + +type Camt053Amt struct { + Ccy string `xml:"Ccy,attr"` + Value float64 `xml:",chardata"` +} + +type Camt053BookgDt struct { + Dt string `xml:"Dt"` +} + +type Camt053BkToCstmrStmt struct { + Stmt Camt053Stmt `xml:"Stmt"` +} + +type Camt053BkTxCd struct { + Domn Camt053Domn `xml:"Domn"` + Prtry Camt053Prtry `xml:"Prtry"` +} + +type Camt053Cdtr struct { + Nm string `xml:"Nm"` +} + +type Camt053CdtrAcct struct { + Id Camt053Id `xml:"Id"` +} + +type Camt053Dbtr struct { + Nm string `xml:"Nm"` +} + +type Camt053DbtrAcct struct { + Id Camt053Id `xml:"Id"` +} + +type Camt053Domn struct { + Cd string `xml:"Cd"` + Fmly Camt053Fmly `xml:"Fmly"` +} + +type Camt053Fmly struct { + Cd string `xml:"Cd"` + SubFmlyCd string `xml:"SubFmlyCd"` +} + +type Camt053Id struct { + IBAN string `xml:"IBAN"` +} + +type Camt053Ntry struct { + NtryRef string `xml:"NtryRef"` + Amt Camt053Amt `xml:"Amt"` + CdtDbtInd string `xml:"CdtDbtInd"` + BookgDt Camt053BookgDt `xml:"BookgDt"` + NtryDtls Camt053NtryDtls `xml:"NtryDtls"` + BkTxCd Camt053BkTxCd `xml:"BkTxCd"` + AddtlNtryInf string `xml:"AddtlNtryInf"` +} + +type Camt053NtryDtls struct { + TxDtls Camt053TxDtls `xml:"TxDtls"` +} + +type Camt053Prtry struct { + Cd string `xml:"Cd"` + Issr string `xml:"Issr"` +} + +type Camt053Refs struct { + EndToEndId string `xml:"EndToEndId"` + TxId string `xml:"TxId"` +} + +type Camt053RltdPties struct { + Cdtr Camt053Cdtr `xml:"Cdtr"` + CdtrAcct Camt053CdtrAcct `xml:"CdtrAcct"` + Dbtr Camt053Dbtr `xml:"Dbtr"` + DbtrAcct Camt053DbtrAcct `xml:"DbtrAcct"` +} + +type Camt053Stmt struct { + Ntry []Camt053Ntry `xml:"Ntry"` + Acct Camt053Acct `xml:"Acct"` +} + +type Camt053TxDtls struct { + Refs Camt053Refs `xml:"Refs"` + RltdPties Camt053RltdPties `xml:"RltdPties"` +} + +// Retrieve a usable string for the description of an Ntry node +func (this *Camt053Ntry) Description(doc Camt053) string { + if this.AddtlNtryInf != "" { + return this.AddtlNtryInf + } + + return "Transfer from " + this.SourceName(doc) + " to " + this.DestinationName(doc) +} + +// Get the destination's IBAN if it exists, otherwise return an empty string. +func (this *Camt053Ntry) DestinationIban(doc Camt053) string { + switch this.CdtDbtInd { + case "CRDT": + switch this.BkTxCd.Domn.Fmly.Cd { + case "RCDT": + return doc.BkToCstmrStmt.Stmt.Acct.Id.IBAN + } + case "DBIT": + switch this.BkTxCd.Domn.Cd { + case "PMNT": + switch this.BkTxCd.Domn.Fmly.Cd { + case "ICDT": + return this.NtryDtls.TxDtls.RltdPties.CdtrAcct.Id.IBAN + } + } + } + + return "" +} + +// Get the destination's name if it exists, otherwise return an empty string. +func (this *Camt053Ntry) DestinationName(doc Camt053) string { + switch this.CdtDbtInd { + case "DBIT": + switch this.BkTxCd.Domn.Cd { + case "ACMT": + return cases.Title(language.Und).String(this.BkTxCd.Prtry.Issr) + case "PMNT": + switch this.BkTxCd.Domn.Fmly.Cd { + case "CCRD": + split := strings.SplitN(this.AddtlNtryInf, ">", 2) + tag := strings.TrimSpace(split[0]) + + return tag + case "ICDT": + return this.NtryDtls.TxDtls.RltdPties.Cdtr.Nm + case "RDDT": + return this.NtryDtls.TxDtls.RltdPties.Cdtr.Nm + } + } + } + + return "" +} + +// Get the source's IBAN if it exists, otherwise return an empty string. +func (this *Camt053Ntry) SourceIban(doc Camt053) string { + switch this.CdtDbtInd { + case "CRDT": + switch this.BkTxCd.Domn.Fmly.Cd { + case "RCDT": + return this.NtryDtls.TxDtls.RltdPties.DbtrAcct.Id.IBAN + } + case "DBIT": + return doc.BkToCstmrStmt.Stmt.Acct.Id.IBAN + } + + return "" +} + +// Get the source's name if it exists, otherwise return an empty string. +func (this *Camt053Ntry) SourceName(doc Camt053) string { + switch this.CdtDbtInd { + case "CRDT": + switch this.BkTxCd.Domn.Fmly.Cd { + case "RCDT": + return this.NtryDtls.TxDtls.RltdPties.Dbtr.Nm + } + } + + return "" +} + +func (this *Camt053Ntry) Type(doc Camt053) string { + if this.CdtDbtInd == "CRDT" { + return "deposit" + } + + return "withdrawal" +} diff --git a/firefly_iii.go b/firefly_iii.go new file mode 100644 index 0000000..7bc3885 --- /dev/null +++ b/firefly_iii.go @@ -0,0 +1,65 @@ +package main + +type FireflyAccountSearch struct { + Data []struct { + Type string `json:"accounts"` + Attributes struct { + Name string `json:"name"` + Type string `json:"type"` + Iban string `json:"iban"` + } `json:"attributes"` + } `json:"data"` + Meta struct { + Pagination struct { + Count uint64 `json:"count"` + } `json:"pagination"` + } `json:"meta"` +} + +type FireflyTransactionPost struct { + //ErrorIfDuplicateHash bool `json:"error_if_duplicate_hash"` + ApplyRules bool `json:"apply_rules"` + FireWebhooks bool `json:"fire_webhooks"` + //GroupTitle string `json:"group_title"` + Transactions []FireflyTransaction `json:"transactions"` +} + +type FireflyTransactionSearch struct { + Meta struct { + Pagination struct { + Count uint64 `json:"count"` + } `json:"pagination"` + } `json:"meta"` +} + +type FireflyTransaction struct { + Type string `json:"type"` + Date string `json:"date"` + Amount string `json:"amount"` + Description string `json:"description"` + //Order uint `json:"order"` + //CurrencyId string `json:"currency_id"` + CurrencyCode string `json:"currency_code"` + //BudgetId string `json:"budget_id"` + //CategoryId string `json:"category_id"` + //CategoryName string `json:"category_name"` + //SourceId string `json:"source_id"` + SourceName string `json:"source_name"` + //DestinationId string `json:"destination_id"` + DestinationName string `json:"destination_name"` + //Reconciled bool `json:"reconciled"` + //PiggyBankId uint `json:"piggy_bank_id"` + //PiggyBankName string `json:"piggy_bank_name"` + //BillId string `json:"bill_id"` + //BillName string `json:"bill_name"` + //Tags []string `json:"tags"` + //Notes string `json:"notes"` + //InternalReference string `json:"internal_reference"` + ExternalId string `json:"external_id"` + //ExternalUrl string `json:"external_url"` + //BookDate string `json:"book_date"` + //ProcessDate string `json:"process_date"` + //DueDate string `json:"due_date"` + //PaymentDate string `json:"payment_date"` + //InvoiceDate string `json:"invoice_date"` +} @@ -0,0 +1,5 @@ +module git.tyil.nl/firefly-importer + +go 1.22.6 + +require golang.org/x/text v0.21.0 @@ -0,0 +1,2 @@ +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/xml" + "flag" + "fmt" + "io" + "log/slog" + "os" +) + +func main() { + // Handle flags + pat := flag.String("pat", "", "The PAT used in Firefly III API calls") + baseUrl := flag.String("url", "", "The base URL of the Firefly III instance") + debug := flag.Bool("debug", false, "Enable debug logging") + dry := flag.Bool("dry-run", false, "Dont post any new records") + header := flag.Bool("header", true, "Show the output table header") + flag.Parse() + + // Set debug logging if desired + if *debug { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + + // Read XML from stdin + stdin, err := io.ReadAll(os.Stdin) + if err != nil { + panic("Failed to read stdin") + } + + doc := Camt053{} + + err = xml.Unmarshal(stdin, &doc) + if err != nil { + fmt.Println("%s\n", err) + panic("Failed to parse XML") + } + + // Show the output header + if *header { + fmt.Printf( + "%-20s %-10.10s %-10s %-22.22s %-22.22s %-6.6s\n", + "ID", + "Date", + "Amount", + "Source", + "Destination", + "Status", + ) + } + + // Loop over all transactions and post them to Firefly III + for _, ntry := range doc.BkToCstmrStmt.Stmt.Ntry { + tx := FireflyTransaction{ + Type: ntry.Type(doc), + Amount: fmt.Sprintf("%.02f", ntry.Amt.Value), + CurrencyCode: ntry.Amt.Ccy, + ExternalId: ntry.NtryRef, + Description: ntry.Description(doc), + DestinationName: ntry.DestinationName(doc), + Date: ntry.BookgDt.Dt + "T00:00:00Z", + } + + // Set the SourceName of the transaction based on the IBAN + sourceIban := ntry.SourceIban(doc) + if sourceIban != "" { + sourceAccountName, err := fireflyApiAccountGetNameByIban(*baseUrl, *pat, sourceIban) + if err != nil { + fmt.Printf("%-6.6s (%s)\n", "!", err) + continue + } + + if sourceAccountName != "" { + tx.SourceName = sourceAccountName + } + } + + // Fall back to just using the given name in the Ntry + if tx.SourceName == "" { + tx.SourceName = ntry.SourceName(doc) + } + + // Set the DestinationName of the transaction based on the IBAN + destinationIban := ntry.DestinationIban(doc) + if destinationIban != "" { + destinationAccountName, err := fireflyApiAccountGetNameByIban(*baseUrl, *pat, destinationIban) + if err != nil { + fmt.Printf("%-6.6s (%s)\n", "!", err) + continue + } + + if destinationAccountName != "" { + tx.DestinationName = destinationAccountName + } + } + + // Fall back to just using the given name in the Ntry + if tx.DestinationName == "" { + tx.DestinationName = ntry.DestinationName(doc) + } + + // Show record + fmt.Printf( + "%-20s %10.10s %7.7s%3s %-22.22s %-22.22s ", + tx.ExternalId, + tx.Date, + tx.Amount, + tx.CurrencyCode, + tx.SourceName, + tx.DestinationName, + ) + + // Check if this record is a duplicate + exists, err := fireflyApiTransactionExistsByExternalId(*baseUrl, *pat, tx.ExternalId) + if err != nil { + fmt.Printf("%-6.6s (%s)\n", "!", err) + continue + } + + if exists { + fmt.Printf("%-6.6s (%s)\n", "⚠", "Duplicate") + continue + } + + // Check if the transaction is a transfer + destinationType, err := fireflyApiAccountGetTypeByName(*baseUrl, *pat, tx.DestinationName) + if err != nil { + fmt.Printf("%-6.6s (%s)\n", "!", err) + continue + } + + sourceType, err := fireflyApiAccountGetTypeByName(*baseUrl, *pat, tx.SourceName) + if err != nil { + fmt.Printf("%-6.6s (%s)\n", "!", err) + continue + } + + if sourceType == "asset" && destinationType == "asset" { + tx.Type = "transfer" + } + + // Post record to Firefly + if !*dry { + err = fireflyApiTransactionAdd(*baseUrl, *pat, tx) + if err != nil { + fmt.Printf("%-6.6s (%s)\n", "!", err) + continue + } + } + + fmt.Printf("%-6.6s (%s)\n", "✔", tx.Type) + } +} |
