package invoice import ( "bufio" "errors" "fmt" "io" "strings" "time" ) func ParseMarkdown(r io.Reader) (Document, error) { var doc Document var section string var inAddress bool var tableHeader []string scanner := bufio.NewScanner(r) lineNo := 0 for scanner.Scan() { lineNo++ line := strings.TrimRight(scanner.Text(), "\r") trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "#") && strings.HasPrefix(trimmed, "## ") { section = strings.TrimSpace(strings.TrimPrefix(trimmed, "## ")) inAddress = false if strings.EqualFold(section, "Prestations") { tableHeader = nil } continue } if trimmed == "" { inAddress = false continue } switch strings.ToLower(section) { case "seller": if ok := parsePartyLine(&doc.Seller, line, &inAddress); ok { continue } case "buyer": if ok := parsePartyLine(&doc.Buyer, line, &inAddress); ok { continue } case "invoice": key, value, ok := splitKeyValue(line) if !ok { return doc, fmt.Errorf("line %d: expected key/value in Invoice section", lineNo) } switch normalizeKey(key) { case "number": doc.Invoice.Number = value case "subject": doc.Invoice.Subject = value case "location": doc.Invoice.Location = value case "date": parsed, err := time.Parse("2006-01-02", value) if err != nil { return doc, fmt.Errorf("line %d: invalid date %q", lineNo, value) } doc.Invoice.Date = parsed case "description": doc.Invoice.Description = value default: return doc, fmt.Errorf("line %d: unknown invoice field %q", lineNo, key) } case "items": if !strings.Contains(trimmed, "|") { return doc, fmt.Errorf("line %d: expected table row in Items section", lineNo) } cells := splitTableRow(line) if len(cells) == 0 { continue } if tableHeader == nil { tableHeader = cells continue } if isSeparatorRow(cells) { continue } item, err := parseItemRow(tableHeader, cells) if err != nil { return doc, fmt.Errorf("line %d: %w", lineNo, err) } doc.Items = append(doc.Items, item) case "payment": key, value, ok := splitKeyValue(line) if !ok { return doc, fmt.Errorf("line %d: expected key/value in Payment section", lineNo) } switch normalizeKey(key) { case "holder": doc.Payment.Holder = value case "iban": doc.Payment.IBAN = value default: return doc, fmt.Errorf("line %d: unknown payment field %q", lineNo, key) } default: continue } } if err := scanner.Err(); err != nil { return doc, err } return doc, nil } func parsePartyLine(party *Party, line string, inAddress *bool) bool { if *inAddress && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { party.Address = append(party.Address, strings.TrimSpace(line)) return true } key, value, ok := splitKeyValue(line) if !ok { return false } switch normalizeKey(key) { case "name": party.Name = value *inAddress = false return true case "address": *inAddress = true if value != "" { party.Address = append(party.Address, value) } return true case "siret": party.SIRET = value *inAddress = false return true case "email": party.Email = value *inAddress = false return true case "phone": party.Phone = value *inAddress = false return true default: return false } } func splitKeyValue(line string) (string, string, bool) { parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { return "", "", false } key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) return key, value, true } func normalizeKey(key string) string { k := strings.ToLower(strings.TrimSpace(key)) replacer := strings.NewReplacer("é", "e", "è", "e", "ê", "e", "à", "a", "ç", "c") return replacer.Replace(k) } func splitTableRow(line string) []string { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, "|") { return nil } trimmed = strings.TrimPrefix(trimmed, "|") trimmed = strings.TrimSuffix(trimmed, "|") parts := strings.Split(trimmed, "|") var cells []string for _, part := range parts { cells = append(cells, strings.TrimSpace(part)) } return cells } func isSeparatorRow(cells []string) bool { for _, cell := range cells { if cell == "" { continue } for _, r := range cell { if r != '-' { return false } } } return true } func parseItemRow(header, row []string) (Item, error) { if len(row) < len(header) { return Item{}, errors.New("row has fewer columns than header") } idxDesignation := -1 idxPrice := -1 idxQty := -1 for i, col := range header { n := normalizeKey(col) switch { case strings.Contains(n, "designation"): idxDesignation = i case strings.Contains(n, "unitprice"), strings.Contains(n, "unit price"), strings.Contains(n, "price"), strings.Contains(n, "prix"): idxPrice = i case strings.Contains(n, "quantity"), strings.Contains(n, "quantite"), strings.Contains(n, "qty"): idxQty = i } } if idxDesignation == -1 || idxPrice == -1 || idxQty == -1 { return Item{}, errors.New("table header must include Designation, Unit price, Quantity") } item := Item{Designation: row[idxDesignation]} if item.Designation == "" { return Item{}, errors.New("empty designation") } price, err := ParseMoney(row[idxPrice]) if err != nil { return Item{}, fmt.Errorf("invalid unit price: %w", err) } item.UnitPrice = price qty, err := ParseQuantity(row[idxQty]) if err != nil { return Item{}, fmt.Errorf("invalid quantity: %w", err) } item.Quantity = qty return item, nil }