summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPatrick Spek <p.spek@tyil.nl>2024-12-07 10:04:02 +0100
committerPatrick Spek <p.spek@tyil.nl>2024-12-07 10:04:02 +0100
commit876a930835aa633ebf4ef3a7499cb54c9736b0fd (patch)
treea6980e242304c38790b265d28f76201e7b15dd59
downloadfirefly-importer-876a930835aa633ebf4ef3a7499cb54c9736b0fd.tar.gz
firefly-importer-876a930835aa633ebf4ef3a7499cb54c9736b0fd.tar.bz2
Initial commit
-rw-r--r--api.go187
-rw-r--r--camt053.go198
-rw-r--r--firefly_iii.go65
-rw-r--r--go.mod5
-rw-r--r--go.sum2
-rw-r--r--main.go154
6 files changed, 611 insertions, 0 deletions
diff --git a/api.go b/api.go
new file mode 100644
index 0000000..2757e04
--- /dev/null
+++ b/api.go
@@ -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"`
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..bb210f5
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,5 @@
+module git.tyil.nl/firefly-importer
+
+go 1.22.6
+
+require golang.org/x/text v0.21.0
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..0d8f5b2
--- /dev/null
+++ b/go.sum
@@ -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=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..cd29f03
--- /dev/null
+++ b/main.go
@@ -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)
+ }
+}