diff --git a/accounting/docs.md b/accounting/docs.md index 34b3b6e..9af00c1 100644 --- a/accounting/docs.md +++ b/accounting/docs.md @@ -84,3 +84,21 @@ A fee entry represents the on chain fees we paid for a transaction. - TxID: The on chain transaction ID. - Reference: TransactionID:-1. - Note: Note set for fees. + +## Off Chain Reports + +### Receipt +Receipts off chain represent invoices that are paid via the Lightning Network. + +- Amount: The amount that we were paid, note that this may be greater than the original invoice value. +- TxID: The payment hash of the invoice. +- Reference: The preimage of the invoice. +- Note: Optionally set if the invoice had a memo attached, was overpaid, or was a keysend. + +### Circular Receipt +Circular receipts record instances where we have paid one of our own invoices. + +- Amount: The amount that we were paid, note that this may be greater than the original invoice value. +- TxID: The payment hash of the invoice. +- Reference: The preimage of the invoice. +- Note: Optionally set if the invoice had a memo attached, was overpaid, or was a keysend. diff --git a/accounting/entries.go b/accounting/entries.go index 8a46fc0..49727e6 100644 --- a/accounting/entries.go +++ b/accounting/entries.go @@ -1,7 +1,9 @@ package accounting import ( + "encoding/hex" "fmt" + "strings" "github.com/lightningnetwork/lnd/lnrpc" ) @@ -191,3 +193,51 @@ func onChainEntries(tx *lnrpc.Transaction, return []*HarmonyEntry{txEntry, feeEntry}, nil } + +// invoiceNote creates an optional note for an invoice if it had a memo, was +// overpaid, or both. +func invoiceNote(memo string, amt, amtPaid int64, keysend bool) string { + var notes []string + + if memo != "" { + notes = append(notes, fmt.Sprintf("memo: %v", memo)) + } + + if amt != amtPaid { + notes = append(notes, fmt.Sprintf("invoice overpaid "+ + "original amount: %v msat, paid: %v", amt, amtPaid)) + } + + if keysend { + notes = append(notes, "keysend payment") + } + + if len(notes) == 0 { + return "" + } + + return strings.Join(notes, "/") +} + +// invoiceEntry creates an entry for an invoice. +func invoiceEntry(invoice *lnrpc.Invoice, circularReceipt bool, + convert msatToFiat) (*HarmonyEntry, error) { + + eventType := EntryTypeReceipt + if circularReceipt { + eventType = EntryTypeCircularReceipt + } + + note := invoiceNote( + invoice.Memo, invoice.ValueMsat, invoice.AmtPaidMsat, + invoice.IsKeysend, + ) + + preimage := hex.EncodeToString(invoice.RPreimage) + hash := hex.EncodeToString(invoice.RHash) + + return newHarmonyEntry( + invoice.SettleDate, invoice.AmtPaidMsat, eventType, + hash, preimage, note, false, convert, + ) +} diff --git a/accounting/entries_test.go b/accounting/entries_test.go index bf286b4..492d2c8 100644 --- a/accounting/entries_test.go +++ b/accounting/entries_test.go @@ -1,6 +1,7 @@ package accounting import ( + "encoding/hex" "fmt" "testing" "time" @@ -88,6 +89,36 @@ var ( TotalFees: onChainFeeSat, DestAddresses: []string{destAddr}, } + + paymentRequest = "lnbcrt10n1p0t6nmypp547evsfyrakg0nmyw59ud9cegkt99yccn5nnp4suq3ac4qyzzgevsdqqcqzpgsp54hvffpajcyddm20k3ptu53930425hpnv8m06nh5jrd6qhq53anrq9qy9qsqphhzyenspf7kfwvm3wyu04fa8cjkmvndyexlnrmh52huwa4tntppjmak703gfln76rvswmsx2cz3utsypzfx40dltesy8nj64ttgemgqtwfnj9" + + invoiceMemo = "memo" + + invoiceAmt = lnwire.MilliSatoshi(300) + + invoiceOverpaidAmt = lnwire.MilliSatoshi(400) + + invoiceSettleTime int64 = 1588159722 + + invoicePreimage = "b5f0c5ac0c873a05702d0aa63a518ecdb8f3ba786be2c4f64a5b10581da976ae" + preimage, _ = hex.DecodeString(invoicePreimage) + + invoiceHash = "afb2c82483ed90f9ec8ea178d2e328b2ca526313a4e61ac3808f715010424659" + hash, _ = hex.DecodeString(invoiceHash) + + invoice = &lnrpc.Invoice{ + Memo: invoiceMemo, + RPreimage: preimage, + RHash: hash, + ValueMsat: int64(invoiceAmt), + CreationDate: 0, + SettleDate: invoiceSettleTime, + PaymentRequest: paymentRequest, + AmtPaidSat: 0, + AmtPaidMsat: int64(invoiceOverpaidAmt), + Htlcs: nil, + IsKeysend: true, + } ) // mockConvert is a mocked price function which returns mockPrice * amount. @@ -388,3 +419,66 @@ func TestOnChainEntry(t *testing.T) { }) } } + +// TestInvoiceEntry tests creation of entries for regular invoices and circular +// receipts. +func TestInvoiceEntry(t *testing.T) { + getEntry := func(circular bool) *HarmonyEntry { + note := invoiceNote( + invoice.Memo, invoice.ValueMsat, invoice.AmtPaidMsat, + invoice.IsKeysend, + ) + + fiat, _ := mockConvert(int64(invoiceOverpaidAmt), 0) + + expectedEntry := &HarmonyEntry{ + Timestamp: time.Unix(invoiceSettleTime, 0), + Amount: invoiceOverpaidAmt, + FiatValue: fiat, + TxID: invoiceHash, + Reference: invoicePreimage, + Note: note, + Type: EntryTypeReceipt, + OnChain: false, + Credit: true, + } + + if circular { + expectedEntry.Type = EntryTypeCircularReceipt + } + + return expectedEntry + } + tests := []struct { + name string + circular bool + }{ + { + name: "regular receive", + circular: false, + }, + + { + name: "circular", + circular: true, + }, + } + + for _, test := range tests { + test := test + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + entry, err := invoiceEntry( + invoice, test.circular, mockConvert, + ) + if err != nil { + t.Fatal(err) + } + + expectedEntry := getEntry(test.circular) + require.Equal(t, expectedEntry, entry) + }) + } +} diff --git a/accounting/report.go b/accounting/report.go index 77f9df2..cd3abe9 100644 --- a/accounting/report.go +++ b/accounting/report.go @@ -124,4 +124,9 @@ const ( // chain routing. Note that this entry type excludes fees for channel // opens and closes. EntryTypeFee + + // EntryTypeCircularReceipt represents an invoice that we paid to + // ourselves. This occurs when circular payments are used to rebalance + // channels. + EntryTypeCircularReceipt )