cln-ledger

CLN ledger accounting
git clone git://jb55.com/cln-ledger
Log | Files | Refs

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);