diff --git a/cmd/createbatch/import_csv.go b/cmd/createbatch/import_csv.go new file mode 100644 index 0000000..bf52148 --- /dev/null +++ b/cmd/createbatch/import_csv.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/csv" + "fmt" + "os" + "strconv" + + "src.wolkict.net/sepa/pain" +) + +// FieldsPerBatchLine are the expected number of fields in each CSV line. +const FieldsPerBatchLine = 9 + +// ImportIndex keeps track of what column corresponds to what data. +type ImportIndex int + +// These are our numbered input fields +const ( + ImportName ImportIndex = iota + ImportAddr1 + ImportAddr2 + ImportCountry + ImportIBAN + ImportMandateId + ImportSignatureDate + ImportAmount + ImportInfo +) + +func ImportCSV(fn string) ([]pain.DrctDbtTxInf, error) { + f, err := os.Open(fn) + if err != nil { + return nil, fmt.Errorf("opening file %q failed: %v", fn, err) + } + defer f.Close() + + csvReader := csv.NewReader(f) + csvReader.FieldsPerRecord = FieldsPerBatchLine + + all, err := csvReader.ReadAll() + if err != nil { + return nil, fmt.Errorf("reading csv %q failed: %v", fn, err) + } + + ret := make([]pain.DrctDbtTxInf, len(all)) + for i, line := range all { + if line[0] == "name" { + // Header line + continue + } + + dbtr := pain.NewDebtor(line[ImportName], line[ImportAddr1], line[ImportAddr2], line[ImportCountry]) + dbtrAcct := pain.NewDbtrAcct(line[ImportIBAN]) + mandateInfo := pain.NewMandateInfo(line[ImportMandateId], line[ImportSignatureDate]) + amount, err := strconv.ParseFloat(line[ImportAmount], 64) + if err != nil { + return nil, fmt.Errorf("error importing line %d, cannot parse amount: %v", i, err) + } + + ret[i] = pain.NewDirectDebitTransaction(dbtr, dbtrAcct, mandateInfo, amount, line[ImportInfo], i) + } + return ret, nil +} diff --git a/cmd/createbatch/main.go b/cmd/createbatch/main.go index fa81385..799a665 100644 --- a/cmd/createbatch/main.go +++ b/cmd/createbatch/main.go @@ -2,18 +2,58 @@ package main import ( "encoding/xml" + "flag" "fmt" "log" + "os" "time" "src.wolkict.net/sepa/pain" ) +var ( + csvFile = flag.String("csv", "", "use this batch import CSV file") + execDate = flag.String("date", time.Now().AddDate(0, 0, 7).Format("2006-01-02"), "manually set batch execution date") + msgId = flag.String("msgId", time.Now().Format("20060102T150405"), "manually set this msgId") +) + +func usage() { + fmt.Println("Usage: createbatch -csv ") + flag.PrintDefaults() +} + func main() { - d := pain.NewDocument("PayPro B.V") - d.SetMeta(CREDITOR_NAME, CREDITOR_ADDR1, CREDITOR_ADDR2, CREDITOR_IBAN, CREDITOR_BIC, CREDITOR_ID, - time.Date(2020, time.January, 30, 12, 0, 0, 0, time.UTC), "RCUR") - err := d.Finalize("asdfasdf") + flag.Parse() + + if *csvFile == "" { + usage() + log.Fatalf("mandatory -csv not set") + } + + if _, err := os.Open(*csvFile); err != nil { + log.Fatalf("cannot access CSV %q: %v", *csvFile, err) + } + + collectionDate, err := time.Parse("2006-01-02", *execDate) + if err != nil { + log.Fatalf("could not parse execution date: %v", err) + } + + batchLines, err := ImportCSV(*csvFile) + if err != nil { + log.Fatalf("cannot import batch CSV: %v", err) + } + + d := pain.NewDocument(INITIATOR_NAME) + d.SetMeta(CREDITOR_NAME, CREDITOR_ADDR1, CREDITOR_ADDR2, CREDITOR_IBAN, CREDITOR_BIC, CREDITOR_ID, collectionDate, "RCUR") + for _, tx := range batchLines { + if tx.Id == (pain.PaymentId{}) { + continue + } + d.AddTransaction(tx) + } + + err = d.Finalize(*msgId) if err != nil { log.Fatalf("finalizing failed: %v", err) } diff --git a/pain/api.go b/pain/api.go index 0b34ee1..77edc84 100644 --- a/pain/api.go +++ b/pain/api.go @@ -2,6 +2,7 @@ package pain import ( "fmt" + "strconv" "time" ) @@ -65,17 +66,12 @@ func (d *Document) SetMeta(creditorName, creditorAddr1, creditorAddr2, creditorI } func (d *Document) Finalize(msgId string) error { - // GroupHeader: - // Set msgId - - /* - if len(d.Contents.PaymentInformation) == 0 { - return fmt.Errorf("no payment information, aborting") - } - if len(d.Contents.TransactionInformation) == 0 { - return fmt.Errorf("no transaction information, aborting") - } - */ + if len(d.Contents.PaymentInformation) == 0 { + return fmt.Errorf("no payment information, aborting") + } + if len(d.Contents.PaymentInformation[0].Transactions) == 0 { + return fmt.Errorf("no transaction information, aborting") + } csum := 0.0 d.Contents.GroupHeader.MessageId = msgId @@ -84,13 +80,23 @@ func (d *Document) Finalize(msgId string) error { d.Contents.GroupHeader.Sum = fmt.Sprintf("%.2f", csum) d.Contents.PaymentInformation[0].Id = msgId + for _, tx := range d.Contents.PaymentInformation[0].Transactions { + flt, err := strconv.ParseFloat(tx.Amount.Value, 64) + if err != nil { + panic(fmt.Errorf("could not parse float from %q: %v", tx.Amount.Value, err)) + } + csum += flt + } + d.Contents.GroupHeader.Sum = fmt.Sprintf("%.2f", csum) + return d.Valid() } -func (d *Document) AddTransaction(tx *DrctDbtTxInf) error { +func (d *Document) AddTransaction(tx DrctDbtTxInf) error { if len(d.Contents.PaymentInformation) == 0 { return fmt.Errorf("payment information not yet initialized") } + d.Contents.PaymentInformation[0].Transactions = append(d.Contents.PaymentInformation[0].Transactions, tx) return nil } diff --git a/pain/pain_regexp.go b/pain/pain_regexp.go index 7d53e80..34dee78 100644 --- a/pain/pain_regexp.go +++ b/pain/pain_regexp.go @@ -33,6 +33,7 @@ var ( "CreDtTm": ISODateTime, "CtrlSum": RestrictedDecimalNumber, "Ctry": CountryCode, + "DtOfSgntr": ISODate, "EndToEndId": RestrictedIdentificationSEPA1, "IBAN": IBAN2007Identifier, "Id": RestrictedPersonIdentifierSEPA, diff --git a/pain/payment_information_transactions.go b/pain/payment_information_transactions.go index b8c2e0f..6b46d13 100644 --- a/pain/payment_information_transactions.go +++ b/pain/payment_information_transactions.go @@ -3,6 +3,7 @@ package pain import ( "fmt" "strings" + "time" ) type DrctDbtTxInf struct { @@ -15,12 +16,11 @@ type DrctDbtTxInf struct { Info RmtInf `xml:"RmtInf"` } -func NewDirectDebitTransaction(debtor Dbtr, account DbtrAcct, mandateInfo MndtRltdInf, amount float64, additionalInfo string) DrctDbtTxInf { - e2eid := fmt.Sprintf("%v%4d", time.Now().Format("20060102"), 4) // TODO: inform this function of current number of transactions +func NewDirectDebitTransaction(debtor Dbtr, account DbtrAcct, mandateInfo MndtRltdInf, amount float64, additionalInfo string, counter int) DrctDbtTxInf { + e2eid := fmt.Sprintf("%v%04d", time.Now().Format("20060102"), counter) return DrctDbtTxInf{ Id: PaymentId{ - InstrumentId: "asdfasdf", - EndToEndId: e2eid, + EndToEndId: e2eid, }, Amount: CurrencyWithAmount{ Currency: "EUR", @@ -31,12 +31,14 @@ func NewDirectDebitTransaction(debtor Dbtr, account DbtrAcct, mandateInfo MndtRl }, Agent: DbtrAgt{ InstitutionId: FinInstnId{ - BIC: "KNABNL2H", // fixme + BIC: "NOTPROVIDED", }, }, Debtor: debtor, Account: account, - Info: additionalInfo, + Info: RmtInf{ + Value: additionalInfo, + }, } } @@ -88,7 +90,7 @@ func (c *CurrencyWithAmount) Valid() error { } type PaymentId struct { - InstrumentId string `xml:"InstrId"` + InstrumentId string `xml:"InstrId,omitempty"` EndToEndId string `xml:"EndToEndId"` } @@ -115,6 +117,14 @@ type MndtRltdInf struct { //ElctrncSgntr is optional } +func NewMandateInfo(id, signatureDate string) MndtRltdInf { + return MndtRltdInf{ + Id: id, + SignatureDate: signatureDate, + IsAmended: false, + } +} + func (m *MndtRltdInf) Valid() error { var err []string if !SEPARegexps["MndtId"].MatchString(m.Id) { @@ -152,6 +162,19 @@ type Dbtr struct { Address PstlAdr `xml:"PstlAdr"` } +func NewDebtor(name, line1, line2, country string) Dbtr { + return Dbtr{ + Name: name, + Address: PstlAdr{ + Country: country, + AddressLines: []AdrLine{ + AdrLine(line1), + AdrLine(line2), + }, + }, + } +} + func (d *Dbtr) Valid() error { var err []string if !SEPARegexps["Nm"].MatchString(d.Name) { @@ -168,7 +191,15 @@ func (d *Dbtr) Valid() error { } type DbtrAcct struct { - Id IBAN `xml:"IBAN"` + Id IBAN `xml:"Id"` +} + +func NewDbtrAcct(iban string) DbtrAcct { + return DbtrAcct{ + Id: IBAN{ + IBAN: iban, + }, + } } func (d *DbtrAcct) Valid() error {