├── .gitignore ├── requirements.txt ├── LICENSE ├── README.md └── gnucash2ledger.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/* 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dateutil==2.8.2 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Miguel Hernandez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gnucash to Ledger 2 | 3 | Convert Gnucash files to ledger file format. Supports arbitrary decimal places. Can convert compressed and uncompressed files. 4 | 5 | ## Install requirements 6 | 7 | Clone the project 8 | 9 | ``` 10 | git clone https://github.com/lodenrogue/gnucash-to-ledger 11 | ``` 12 | 13 | Change into the gnucash-to-ledger directory 14 | 15 | ``` 16 | cd gnucash-to-ledger 17 | ``` 18 | 19 | Copy your gnucash file to the current directory 20 | 21 | ``` 22 | cp path/to/myfile.gnucash . 23 | ``` 24 | 25 | Install python requirements 26 | 27 | ``` 28 | pip install -r requirements.txt 29 | ``` 30 | 31 | ## How to run 32 | 33 | The script takes a required gnucash file as the first argument and an optional output file. 34 | If no output is given the output will be redirected to stdout. 35 | If a file already exists with the same name as output file nothing is written. 36 | 37 | ``` 38 | python3 gnucash2ledger.py gnucash_file.gnucash output 39 | ``` 40 | 41 | ## Optional: Make the script executable 42 | 43 | Make script executable 44 | 45 | ``` 46 | chmod +x gnucash2ledger.py 47 | ``` 48 | 49 | You can now add it to your PATH so its usable anywhere 50 | 51 | --- 52 | 53 | 54 | Original code source found here: https://gist.github.com/nonducor/ddc97e787810d52d067206a592a35ea7/ 55 | -------------------------------------------------------------------------------- /gnucash2ledger.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | # Redistribution and use in source and binary forms, with or without 4 | # modification, are permitted provided that the following conditions are 5 | # met: 6 | # 7 | # (1) Redistributions of source code must retain the above copyright 8 | # notice, this list of conditions and the following disclaimer. 9 | # 10 | # (2) Redistributions in binary form must reproduce the above copyright 11 | # notice, this list of conditions and the following disclaimer in 12 | # the documentation and/or other materials provided with the 13 | # distribution. 14 | # 15 | # (3)The name of the author may not be used to 16 | # endorse or promote products derived from this software without 17 | # specific prior written permission. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 20 | # IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 21 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | # DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, 23 | # INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 24 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 26 | # HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 27 | # STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING 28 | # IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | # POSSIBILITY OF SUCH DAMAGE. 30 | 31 | import os 32 | import sys 33 | import dateutil.parser 34 | import xml.etree.ElementTree 35 | import gzip 36 | 37 | nss = {'gnc': 'http://www.gnucash.org/XML/gnc', 38 | 'act': 'http://www.gnucash.org/XML/act', 39 | 'book': 'http://www.gnucash.org/XML/book', 40 | 'cd': 'http://www.gnucash.org/XML/cd', 41 | 'cmdty': 'http://www.gnucash.org/XML/cmdty', 42 | 'price': 'http://www.gnucash.org/XML/price', 43 | 'slot': 'http://www.gnucash.org/XML/slot', 44 | 'split': 'http://www.gnucash.org/XML/split', 45 | 'sx': 'http://www.gnucash.org/XML/sx', 46 | 'trn': 'http://www.gnucash.org/XML/trn', 47 | 'ts': 'http://www.gnucash.org/XML/ts', 48 | 'fs': 'http://www.gnucash.org/XML/fs', 49 | 'bgt': 'http://www.gnucash.org/XML/bgt', 50 | 'recurrence': 'http://www.gnucash.org/XML/recurrence', 51 | 'lot': 'http://www.gnucash.org/XML/lot', 52 | 'addr': 'http://www.gnucash.org/XML/addr', 53 | 'owner': 'http://www.gnucash.org/XML/owner', 54 | 'billterm': 'http://www.gnucash.org/XML/billterm', 55 | 'bt-days': 'http://www.gnucash.org/XML/bt-days', 56 | 'bt-prox': 'http://www.gnucash.org/XML/bt-prox', 57 | 'cust': 'http://www.gnucash.org/XML/cust', 58 | 'employee': 'http://www.gnucash.org/XML/employee', 59 | 'entry': 'http://www.gnucash.org/XML/entry', 60 | 'invoice': 'http://www.gnucash.org/XML/invoice', 61 | 'job': 'http://www.gnucash.org/XML/job', 62 | 'order': 'http://www.gnucash.org/XML/order', 63 | 'taxtable': 'http://www.gnucash.org/XML/taxtable', 64 | 'tte': 'http://www.gnucash.org/XML/tte', 65 | 'vendor': 'http://www.gnucash.org/XML/vendor', } 66 | 67 | 68 | class DefaultAttributeProducer: 69 | def __init__(self, defaultValue): 70 | self.__defaultValue = defaultValue 71 | 72 | def __getattr__(self, value): 73 | return self.__defaultValue 74 | 75 | 76 | def orElse(var, default=''): 77 | if var is None: 78 | return DefaultAttributeProducer(default) 79 | else: 80 | return var 81 | 82 | 83 | class Commodity: 84 | def __init__(self, e): 85 | """From a XML e representing a commodity, generates a representation of 86 | the commodity 87 | """ 88 | 89 | self.space = orElse(e.find('cmdty:space', nss)).text 90 | self.id = orElse(e.find('cmdty:id', nss)).text 91 | self.name = orElse(e.find('cmdty:name', nss)).text 92 | 93 | def toLedgerFormat(self, indent=0): 94 | """Format the commodity in a way good to be interpreted by ledger. 95 | 96 | If provided, `indent` will be the indentation (in spaces) of the entry. 97 | """ 98 | outPattern = ('{spaces}commodity {id}\n' 99 | '{spaces} note {name} ({space}:{id})\n') 100 | return outPattern.format(spaces=' '*indent, **self.__dict__) 101 | 102 | 103 | class Account: 104 | def __init__(self, accountDb, e): 105 | self.accountDb = accountDb 106 | self.name = e.find('act:name', nss).text 107 | self.id = e.find('act:id', nss).text 108 | self.accountDb[self.id] = self 109 | self.description = orElse(e.find('act:description', nss)).text 110 | self.type = e.find('act:type', nss).text 111 | self.parent = orElse(e.find('act:parent', nss), None).text 112 | self.used = False # Mark accounts that were in a transaction 113 | self.commodity = orElse(e.find('act:commodity/cmdty:id', nss), None).text 114 | 115 | def getParent(self): 116 | return self.accountDb[self.parent] 117 | 118 | def fullName(self): 119 | if self.parent is not None and self.getParent().type != 'ROOT': 120 | prefix = self.getParent().fullName() + ':' 121 | else: 122 | prefix = '' # ROOT will not be displayed 123 | return prefix + self.name 124 | 125 | def toLedgerFormat(self, indent=0): 126 | outPattern = ('{spaces}account {fullName}\n' 127 | '{spaces} note {description} (type: {type})\n') 128 | return outPattern.format(spaces=' '*indent, fullName=self.fullName(), 129 | **self.__dict__) 130 | 131 | 132 | class Split: 133 | """Represents a single split in a transaction""" 134 | 135 | def __init__(self, accountDb, e): 136 | self.accountDb = accountDb 137 | self.reconciled = e.find('split:reconciled-state', nss).text == 'y' 138 | self.accountId = e.find('split:account', nss).text 139 | accountDb[self.accountId].used = True 140 | 141 | # Some special treatment for value and quantity 142 | rawValue = e.find('split:value', nss).text 143 | self.value = self.convertValue(rawValue) 144 | 145 | # Quantity is the amount on the commodity of the account 146 | rawQuantity = e.find('split:quantity', nss).text 147 | self.quantity = self.convertValue(rawQuantity) 148 | 149 | def getAccount(self): 150 | return self.accountDb[self.accountId] 151 | 152 | def toLedgerFormat(self, commodity='$', indent=0): 153 | outPattern = '{spaces} {flag}{accountName} {value}' 154 | 155 | # Check if commodity conversion will be needed 156 | conversion = '' 157 | if commodity == self.getAccount().commodity: 158 | value = '{value} {commodity}'.format(commodity=commodity, 159 | value=self.value) 160 | else: 161 | conversion = ' {destValue} "{destCmdty}" @@ {value} {commodity}' 162 | realValue = self.value[1:] if self.value.startswith('-') else self.value 163 | value = conversion.format(destValue=self.quantity, 164 | destCmdty=self.getAccount().commodity, 165 | value=realValue, 166 | commodity=commodity) 167 | 168 | return outPattern.format(flag='* ' if self.reconciled else '', 169 | spaces=indent*' ', 170 | accountName=self.getAccount().fullName(), 171 | conversion=conversion, 172 | value=value) 173 | 174 | def convertValue(self, rawValue): 175 | (intValue, decPoint) = rawValue.split('/') 176 | 177 | n = len(decPoint) - 1 178 | signFlag = intValue.startswith('-') 179 | if signFlag: 180 | intValue = intValue[1:] 181 | if len(intValue) < n+1: 182 | intValue = '0'*(n+1-len(intValue)) + intValue 183 | if signFlag: 184 | intValue = '-' + intValue 185 | return intValue[:-n] + '.' + intValue[-n:] 186 | 187 | class Transaction: 188 | def __init__(self, accountDb, e): 189 | self.accountDb = accountDb 190 | self.date = dateutil.parser.parse(e.find('trn:date-posted/ts:date', 191 | nss).text) 192 | self.commodity = e.find('trn:currency/cmdty:id', nss).text 193 | self.description = e.find('trn:description', nss).text 194 | self.splits = [Split(accountDb, s) 195 | for s in e.findall('trn:splits/trn:split', nss)] 196 | 197 | def toLedgerFormat(self, indent=0): 198 | outPattern = ('{spaces}{date} {description}\n' 199 | '{splits}\n') 200 | splits = '\n'.join(s.toLedgerFormat(self.commodity, indent) 201 | for s in self.splits) 202 | return outPattern.format( 203 | spaces=' '*indent, 204 | date=self.date.strftime('%Y/%m/%d'), 205 | description=self.description, 206 | splits=splits) 207 | 208 | def read_file(file_name): 209 | try: 210 | with gzip.open(file_name, 'rt') as f: 211 | return f.read() 212 | except gzip.BadGzipFile: 213 | with open(file_name, 'r') as f: 214 | return f.read() 215 | 216 | 217 | def convert2Ledger(inputFile): 218 | """Reads a gnucash file and converts it to a ledger file.""" 219 | file = read_file(inputFile) 220 | 221 | e = xml.etree.ElementTree.fromstring(file) 222 | b = e.find('gnc:book', nss) 223 | 224 | # Find all commodities 225 | commodities = [] 226 | for cmdty in b.findall('gnc:commodity', nss): 227 | commodities.append(Commodity(cmdty)) 228 | 229 | # Find all accounts 230 | accountDb = {} 231 | for acc in b.findall('gnc:account', nss): 232 | Account(accountDb, acc) 233 | 234 | # Finally, find all transactions 235 | transactions = [] 236 | for xact in b.findall('gnc:transaction', nss): 237 | transactions.append(Transaction(accountDb, xact)) 238 | 239 | # Generate output 240 | output = '' 241 | 242 | # First, add the commodities definition 243 | output = '\n'.join(c.toLedgerFormat() for c in commodities) 244 | output += '\n\n' 245 | 246 | # Then, output all accounts 247 | output += '\n'.join(a.toLedgerFormat() 248 | for a in accountDb.values() if a.used) 249 | output += '\n\n' 250 | 251 | # And finally, output all transactions 252 | output += '\n'.join(t.toLedgerFormat() 253 | for t in sorted(transactions, key=lambda x: x.date)) 254 | 255 | return (output, commodities, accountDb, transactions) 256 | 257 | 258 | if __name__ == '__main__': 259 | if len(sys.argv) not in (2, 3): 260 | print('Usage: gcash2ledger.py inputXMLFile [outputLedgerFile]\n') 261 | print('If output is not provided, output to stdout') 262 | print('If output exists, it will not be overwritten.') 263 | exit(1) 264 | 265 | if len(sys.argv) == 3 and os.path.exists(sys.argv[2]): 266 | print('Output file exists. It will not be overwritten.') 267 | exit(2) 268 | 269 | (data, commodities, accountDb, transactions) = convert2Ledger(sys.argv[1]) 270 | 271 | if len(sys.argv) == 3: 272 | with open(sys.argv[2], 'w') as fh: 273 | fh.write(data) 274 | else: 275 | print(data) 276 | 277 | --------------------------------------------------------------------------------