index.ts (6186B)
1 2 import fs from 'fs'; 3 import readline from 'readline'; 4 import assert from 'assert'; 5 6 interface Zap { 7 kind: number; 8 content: string; 9 pubkey: string; 10 tags: string[][]; 11 } 12 13 // Zap Types 14 interface ZapProfileType { 15 type: "profile" // profile 16 pubkey: string 17 } 18 19 interface ZapEventType { 20 type: "event" 21 evid: string 22 pubkey: string 23 } 24 25 type ZapType = ZapProfileType | ZapEventType 26 27 // Descriptions 28 interface GenericDesc { 29 type: "generic" // generic 30 value: string 31 } 32 33 interface LNDesc { 34 type: "lnurl" // lnurl 35 description: string 36 address: string 37 } 38 39 interface ZapDesc { 40 type: "zap" // zap 41 zap_type: ZapType 42 zap: Zap 43 } 44 45 interface Routed { 46 type: "routed" 47 } 48 49 type Description = LNDesc | GenericDesc | ZapDesc | Routed 50 51 interface Posting { 52 account: string; 53 amount: string; 54 } 55 56 interface Transaction { 57 date: Date; 58 description: Description; 59 postings: Posting[]; 60 } 61 62 function description_string(desc: Description): string { 63 switch (desc.type) { 64 case "generic": 65 return desc.value 66 case "lnurl": 67 return desc.description 68 case "zap": 69 return "Zap" 70 } 71 } 72 73 function determine_zap_type(zap: Zap): ZapType { 74 const etag = get_tag(zap.tags, "e") 75 const ptag = get_tag(zap.tags, "p") 76 77 if (!ptag) 78 return null 79 80 if (!etag) { 81 let profile_zap: ZapProfileType = { pubkey: ptag, type: "profile" } 82 return profile_zap 83 } 84 85 let zap_ev: ZapEventType = { evid: etag, pubkey: ptag, type: "event" } 86 return zap_ev 87 } 88 89 function get_tag(tags: string[][], key: string): string { 90 let v = tags.find(tag => tag[0] === key) 91 return v && v[1] 92 } 93 94 function get_lnurl_description(json: string[][]): LNDesc { 95 const description = get_tag(json, "text/plain") 96 const address = get_tag(json, "text/identifier") 97 return { description, address, type: "lnurl" } 98 } 99 100 function is_json(str: string): boolean { 101 return str[0] === '{' || (str[0] === '[' && str[1] === '[') 102 } 103 104 function create_generic_desc(value: string): GenericDesc { 105 return { type: "generic", value } 106 } 107 108 function determine_description(str: string, tag: string): Description { 109 if (tag === "routed") { 110 let desc: Routed = { type: "routed" } 111 return desc 112 } 113 114 try { 115 let json 116 if (is_json(str) && (json = JSON.parse(str))) { 117 if (json.kind === 9734) { 118 let zaptype: ZapType = determine_zap_type(json) 119 let zap_desc: ZapDesc = { zap_type: zaptype, zap: json, type: "zap" } 120 return zap_desc 121 } else if (json.length > 0) { 122 return get_lnurl_description(json) 123 } 124 125 return create_generic_desc(str) 126 } 127 128 return create_generic_desc(str) 129 } catch(e) { 130 return create_generic_desc(str) 131 } 132 } 133 134 function msat(val: number): string { 135 return `${val} msat` 136 } 137 138 function classify_account(str: string, debit: number):string { 139 if (str.includes("Damus Merch")) 140 return "merch:tshirt" 141 if (str.includes("Damus Hat")) 142 return "merch:hat" 143 if (str.includes("@tipjar")) 144 return "lnurl:jb55@sendsats.lol:tipjar" 145 146 if (debit === 1971000) 147 return "zap:1971" 148 else if (debit === 420000) 149 return "zap:420" 150 151 return "unknown" 152 } 153 154 function determine_postings(credit: number, debit: number, desc: Description): Posting[] { 155 let postings: Posting[] = [] 156 const is_credit = credit > 0 157 const acct = is_credit? "income" : "expenses" 158 const amount = is_credit? credit : debit 159 160 switch (desc.type) { 161 case "routed": 162 postings.push({ account: `${acct}:routed`, amount: msat(is_credit? -amount : amount) }) 163 postings.push({ account: `assets:cln`, amount: msat(is_credit? amount : -amount) }) 164 break 165 case "generic": 166 // todo: categorize 167 const subacct = classify_account(desc.value, debit) 168 postings.push({ account: `${acct}:${subacct}`, amount: msat(is_credit? -amount : amount) }) 169 postings.push({ account: `assets:cln`, amount: msat(is_credit? amount : -amount) }) 170 break 171 case "lnurl": 172 assert(debit === 0) 173 postings.push({ account: `income:lnurl:${desc.address}`, amount: msat(-credit) }) 174 postings.push({ account: `assets:cln`, amount: msat(credit) }) 175 break 176 case "zap": 177 assert(debit === 0) 178 switch (desc.zap_type.type) { 179 case 'profile': 180 postings.push({ account: `income:zap:profile:${desc.zap_type.pubkey}`, amount: msat(-credit) }) 181 break 182 case 'event': 183 // todo: attribute this to profile as well? 184 let evtype = desc.zap_type as ZapEventType 185 postings.push({ account: `income:zap:event:${evtype.evid}`, amount: msat(-credit) }) 186 break 187 } 188 postings.push({ account: `assets:cln`, amount: msat(credit) }) 189 break 190 } 191 192 return postings 193 } 194 195 async function process(filePath: string) { 196 const fileStream = fs.createReadStream(filePath); 197 198 const rl = readline.createInterface({ 199 input: fileStream, 200 crlfDelay: Infinity 201 }); 202 203 console.log("P 2022-07-22 msat 0.00000035 CAD") 204 console.log("P 2022-07-22 msat 0.001 sat") 205 console.log("P 2022-07-22 msat 0.00001 bit") 206 console.log("P 2022-07-22 msat 0.00000000001 btc") 207 208 for await (const line of rl) { 209 let parts = line.split('\t'); 210 211 if(parts.length !== 6) { 212 console.error(`Invalid line: ${line} ${parts.length} !== 5`); 213 continue; 214 } 215 216 const credit = Number(parts[2]) 217 const debit = Number(parts[3]) 218 219 if (credit === 0 && debit === 0) 220 continue 221 222 const tag = parts[1] 223 224 if (!(tag === "invoice" || tag === "routed")) 225 continue 226 227 const timestamp = Number(parts[4]) 228 const date = new Date(timestamp * 1000) 229 const description = determine_description(parts[5], tag) 230 const postings = determine_postings(credit, debit, description) 231 232 // type,.tag,.credit_msat,.debit_msat,.timestamp,.description 233 let transaction: Transaction = { date, description, postings }; 234 console.log(transactionToLedger(transaction)); 235 } 236 } 237 238 function format_date(date: Date): string { 239 const year = date.getFullYear(); 240 const month = (date.getMonth() + 1).toString().padStart(2, '0'); 241 const day = date.getDate().toString().padStart(2, '0'); 242 243 return `${year}-${month}-${day}`; 244 } 245 246 function transactionToLedger(transaction: Transaction): string { 247 const tx = `${format_date(transaction.date)} ${description_string(transaction.description)}\n` 248 return tx + transaction.postings.map(p => ` ${p.account} ${p.amount}`).join("\n") + "\n" 249 } 250 251 async function main() { 252 await process('events.txt'); 253 } 254 255 main().catch(console.error);