From 5bfa6dbe212e08f219c7d408c8760232767c64ab Mon Sep 17 00:00:00 2001 From: Thomas Rademaker Date: Tue, 21 Nov 2023 17:51:19 -0500 Subject: [PATCH] WIP invoice service --- .../{AccountService => }/Currency.swift | 2 + .../Models/InvoiceService/Bolt11Invoice.swift | 13 + .../InvoiceService/CreatedInvoice.swift | 7 + .../Models/InvoiceService/Invoice.swift | 229 ++++++++++++++++++ .../InvoiceHistoryUploadModel.swift | 21 ++ .../InvoiceService/InvoiceUploadModel.swift | 45 ++++ .../Models/{AccountService => }/Unit.swift | 0 Sources/AlbyKit/Services/InvoiceService.swift | 110 +++++++++ Sources/AlbyKit/Services/ServiceHelper.swift | 14 ++ 9 files changed, 441 insertions(+) rename Sources/AlbyKit/Models/{AccountService => }/Currency.swift (57%) create mode 100644 Sources/AlbyKit/Models/InvoiceService/Bolt11Invoice.swift create mode 100644 Sources/AlbyKit/Models/InvoiceService/CreatedInvoice.swift create mode 100644 Sources/AlbyKit/Models/InvoiceService/Invoice.swift create mode 100644 Sources/AlbyKit/Models/InvoiceService/InvoiceHistoryUploadModel.swift create mode 100644 Sources/AlbyKit/Models/InvoiceService/InvoiceUploadModel.swift rename Sources/AlbyKit/Models/{AccountService => }/Unit.swift (100%) create mode 100644 Sources/AlbyKit/Services/InvoiceService.swift create mode 100644 Sources/AlbyKit/Services/ServiceHelper.swift diff --git a/Sources/AlbyKit/Models/AccountService/Currency.swift b/Sources/AlbyKit/Models/Currency.swift similarity index 57% rename from Sources/AlbyKit/Models/AccountService/Currency.swift rename to Sources/AlbyKit/Models/Currency.swift index 0a24fd2..8d137fd 100644 --- a/Sources/AlbyKit/Models/AccountService/Currency.swift +++ b/Sources/AlbyKit/Models/Currency.swift @@ -1,4 +1,6 @@ public enum Currency: String, Codable, Sendable { case bitcoin = "BTC" + case dollars = "USD" +// "bc" // TODO: what is bc? } diff --git a/Sources/AlbyKit/Models/InvoiceService/Bolt11Invoice.swift b/Sources/AlbyKit/Models/InvoiceService/Bolt11Invoice.swift new file mode 100644 index 0000000..5a49640 --- /dev/null +++ b/Sources/AlbyKit/Models/InvoiceService/Bolt11Invoice.swift @@ -0,0 +1,13 @@ +public struct Bolt11Invoice: Codable, Sendable { + public let currency: Currency + public let createdAt: Int // 1693210330, // TODO: should this be a date? + public let expiry: Int + public let payee: String + public let msatoshi: Int + public let description: String + public let paymentHash: String + public let minFinalCltvExpiry: Int + public let amount: Int + public let payeeAlias: String + public let routeHintAliases: [String] // TODO: is string array the correct type? +} diff --git a/Sources/AlbyKit/Models/InvoiceService/CreatedInvoice.swift b/Sources/AlbyKit/Models/InvoiceService/CreatedInvoice.swift new file mode 100644 index 0000000..3fb8a4f --- /dev/null +++ b/Sources/AlbyKit/Models/InvoiceService/CreatedInvoice.swift @@ -0,0 +1,7 @@ +import Foundation + +public struct CreatedInvoice: Codable, Sendable { + public let expires_at: Date // "2022-06-02T08:31:15Z", // TODO: add proper codable date support + public let paymentHash: String + public let paymentRequest: String +} diff --git a/Sources/AlbyKit/Models/InvoiceService/Invoice.swift b/Sources/AlbyKit/Models/InvoiceService/Invoice.swift new file mode 100644 index 0000000..663058b --- /dev/null +++ b/Sources/AlbyKit/Models/InvoiceService/Invoice.swift @@ -0,0 +1,229 @@ +// +// File.swift +// +// +// Created by Thomas Rademaker on 11/21/23. +// + +import Foundation + +// TODO: which properties are optional +public struct Invoice: Codable, Sendable { + public let amount: Int + public let boostagram: Boostagram? + public let comment: String? + public let createdAt: Date // "2022-07-05T17:02:20.343Z", TODO: date set properly in codable + public let creationDate: Int // 1657040540, TODO: this is a date but isn't the same format, can we make this work with Codabale + public let currency: Currency + public let customRecords: [String : String] + public let descriptionHash: String? + public let expiresAt: Date // "2022-07-05T17:17:20.000Z", TODO: more date stuff + public let expiry: Int + public let identifier: String + public let keysendMessage: String? + public let memo: String + public let payerName: String + public let payerPubkey: String? + public let preimage: String + public let paymentHash: String + public let paymentRequest: String + public let rHashStr: String + public let settled: Bool? + public let settledAt: Date // TODO: more date stuff "2022-07-05T17:02:20.000Z", + public let state: InvoiceState + public let type: String + public let value: Int +} + +public enum InvoiceState: String, Codable, Sendable { + case created = "CREATED" + case settled = "SETTLED" +} + +public struct Boostagram: Codable, Sendable { + public let action: String + public let appName: String + public let boostLink: String + public let episode: String + public let feedID: Int + public let itemID: Int + public let message: String? + public let name: String + public let podcast: String + public let senderId: String + public let senderName: String + public let time: String + public let ts: Int + public let url: String + public let valueMsatTotal: Int +} + +/* +[ +{ + "amount": 3, + "boostagram": { + "action": "stream", + "app_name": "Fountain", + "boost_link": "https://fountain.fm/episode/xxx", + "episode": "Your episode", + "feedID": 123456789, + "itemID": 123456789, + "message": null, + "name": "Podcaster", + "podcast": "Your podcast", + "sender_id": "jack", + "sender_name": "@jack", + "time": "00:02:07", + "ts": 127, + "url": "https://media.rss.com/yourpodcast/feed.xml", + "value_msat_total": 3000 + }, + "comment": null, + "created_at": "2022-07-05T17:02:20.343Z", + "creation_date": 1657040540, + "currency": "btc", + "custom_records": { + "5482373484": "kW5TLmnj718m6+Z+DPW3xfvHa25mLrQyzgW0iKbQ5xM=", + "696969": "TGx6bGpaS25NQVRKeEM2ejl0MTk=", + "7629169": "eyJ2YWx1ZV9tc2F0X3RvdGFsIjozMDAwLCJuYW1lIjoiUG9kY2FzdGVyIiwicG9kY2FzdCI6IkFsYnkgcG9kY2FzdCIsImZlZWRJRCI6NDc4MTE5MiwidXJsIjoiaHR0cHM6Ly9tZWRpYS5yc3MuY29tL2ZsaXR6cG9kY2FzdC9mZWVkLnhtbCIsImFjdGlvbiI6InN0cmVhbSIsIm1lc3NhZ2UiOm51bGwsImFwcF9uYW1lIjoiRm91bnRhaW4iLCJzZW5kZXJfaWQiOiJVYkYwckRVeHJHeWpZcWVKUnhwdCIsInNlbmRlcl9uYW1lIjoiQGFkdWluMTgiLCJlcGlzb2RlIjoiVGVzdCBlcGlzb2RlIDEiLCJpdGVtSUQiOjgxNzY5MTEwMDYsInRzIjoxMjcsInRpbWUiOiIwMDowMjowNyIsImJvb3N0X2xpbmsiOiJodHRwczovL2ZvdW50YWluLmZtL2VwaXNvZGUvODE3NjkxMTAwNiJ9" + }, + "description_hash": null, + "expires_at": "2022-07-05T17:17:20.000Z", + "expiry": 900, + "identifier": "RGoRuWDG1bo9zMwNFFMXrKWA", + "keysend_message": null, + "memo": "", + "payer_name": "@aduin18", + "payer_pubkey": null, + "preimage": "a8768f68cdb405d37b4409766333a266959a75bf64380324a6c2271bac6ec6fd", + "payment_hash": "1c88e39b9247ade85f48a0f8b57ce1b12e9e220a4bc35edf93e98b2f3c1fc08b", + "payment_request": "", + "r_hash_str": "1c88e39b9247ade85f48a0f8b57ce1b12e9e220a4bc35edf93e98b2f3c1fc08b", + "settled": true, + "settled_at": "2022-07-05T17:02:20.000Z", + "state": "SETTLED", + "type": "incoming", + "value": 3 + } + +] + + [ + { + "amount": 300, + "boostagram": null, + "comment": null, + "created_at": "2022-06-21T11:04:07.237Z", + "creation_date": 1655809370, + "currency": "btc", + "custom_records": null, + "description_hash": null, + "expires_at": "2022-06-21T11:17:50.000Z", + "expiry": 900, + "identifier": "cdyv5AUpMZeEgEAfsJrbTSMc", + "keysend_message": null, + "memo": "Feed Chickens @ pollofeed.com", + "payer_name": null, + "payer_pubkey": null, + "payment_hash": "987f8f9fb750873183a34716359c917d961b0b1a816e4b176a63624a89897678", + "payment_request": "lnbc3u1p3trf2ksp54a202vea0n3cr9u6evh7rqt858e4jzlwzqvjc4q2la0df2n6qntspp5nplcl8ah2zrnrqargutrt8y30ktpkzc6s9hyk9m2vd3y4zvfweuqdp0gejk2epqgd5xjcmtv4h8xgzqypcx7mrvdanx2ety9e3k7mgxqz95cqpjrzjq2j39c6dx9ea09gkx000aumgv4m4ghu444x7pztl8gnpfka5amkazz60ysqq4dcqqvqqqqqqqqqqqvsq9q9qyysgq4me57k6m7xxsf3fhdq86jk6e29hdsl9h4vjqm5y47vp8cgcfwcfsd7f27t6t73sw87g8l09j4kjxz77dl0kht86qwmxqf9l0nrqwkuqp9yex3z", + "preimage": "a8768f68cdb405d37b4409766333a266959a75bf64380324a6c2271bac6ec6fd", + "r_hash_str": "987f8f9fb750873183a34716359c917d961b0b1a816e4b176a63624a89897678", + "settled": true, + "settled_at": "2022-06-21T11:04:05.000Z", + "state": "SETTLED", + "type": "outgoing", + "value": 300 + }, + ] +*/ + + +// settled invoice +/* +{ + "amount": 3, + "boostagram": { + "action": "stream", + "app_name": "Fountain", + "boost_link": "https://fountain.fm/episode/xxx", + "episode": "Your episode", + "feedID": 123456789, + "itemID": 123456789, + "message": null, + "name": "Podcaster", + "podcast": "Your podcast", + "sender_id": "jack", + "sender_name": "@jack", + "time": "00:02:07", + "ts": 127, + "url": "https://media.rss.com/yourpodcast/feed.xml", + "value_msat_total": 3000 + }, + "comment": null, + "created_at": "2022-07-05T17:02:20.343Z", + "creation_date": 1657040540, + "currency": "btc", + "custom_records": { + "5482373484": "kW5TLmnj718m6+Z+DPW3xfvHa25mLrQyzgW0iKbQ5xM=", + "696969": "TGx6bGpaS25NQVRKeEM2ejl0MTk=", + "7629169": "eyJ2YWx1ZV9tc2F0X3RvdGFsIjozMDAwLCJuYW1lIjoiUG9kY2FzdGVyIiwicG9kY2FzdCI6IkFsYnkgcG9kY2FzdCIsImZlZWRJRCI6NDc4MTE5MiwidXJsIjoiaHR0cHM6Ly9tZWRpYS5yc3MuY29tL2ZsaXR6cG9kY2FzdC9mZWVkLnhtbCIsImFjdGlvbiI6InN0cmVhbSIsIm1lc3NhZ2UiOm51bGwsImFwcF9uYW1lIjoiRm91bnRhaW4iLCJzZW5kZXJfaWQiOiJVYkYwckRVeHJHeWpZcWVKUnhwdCIsInNlbmRlcl9uYW1lIjoiQGFkdWluMTgiLCJlcGlzb2RlIjoiVGVzdCBlcGlzb2RlIDEiLCJpdGVtSUQiOjgxNzY5MTEwMDYsInRzIjoxMjcsInRpbWUiOiIwMDowMjowNyIsImJvb3N0X2xpbmsiOiJodHRwczovL2ZvdW50YWluLmZtL2VwaXNvZGUvODE3NjkxMTAwNiJ9" + }, + "description_hash": null, + "expires_at": "2022-07-05T17:17:20.000Z", + "expiry": 900, + "identifier": "RGoRuWDG1bo9zMwNFFMXrKWA", + "keysend_message": null, + "memo": "", + "payer_name": "@aduin18", + "payer_pubkey": null, + "payment_hash": "1c88e39b9247ade85f48a0f8b57ce1b12e9e220a4bc35edf93e98b2f3c1fc08b", + "payment_request": "", + "r_hash_str": "1c88e39b9247ade85f48a0f8b57ce1b12e9e220a4bc35edf93e98b2f3c1fc08b", + "settled": true, + "settled_at": "2022-07-05T17:02:20.000Z", + "state": "SETTLED", + "type": "incoming", + "value": 3 + } +*/ + +// unsettled invoice +/* +{ + "amount": 1000, + "boostagram": null, + "comment": null, + "created_at": "2023-11-08T04:58:11.205Z", + "creation_date": 1699419491, + "currency": "btc", + "custom_records": null, + "description_hash": null, + "expires_at": "2023-11-09T04:58:11.000Z", + "expiry": 86400, + "fiat_currency": "USD", + "fiat_in_cents": 36, + "identifier": "6vaChhm17PqoJ5LJQi1THpUC", + "keysend_message": null, + "memo": null, + "payer_name": null, + "payer_email": null, + "payer_pubkey": null, + "payment_hash": "c169e73f4eb85de8bfa345d0b650b6ffb64054ebb57571e3d3a235b6ba616fe0", + "payment_request": "lnbc10u1pj5k9trpp5c957w06whpw730arghgtv59kl7myq48tk46hrc7n5g6mdwnpdlsqdp8xemxzsmgdpknzd6sw9h55d2vffgkjv25fpc92sccqzzsxqyz5vqsp52aauy758lcg0hkda65kcrffrlsgum97gt6ywu8r0fz6cacwy0x7q9qyyssqyafv2eamugl5e79wrcu7pzllwyq8kcn5fw8l6njjqtzsfthsd6452nwyjxmpmpek8jrt4j6vtm4wq8dkj34wewz0707yvdgqxqsuhyspxmsmld", + "r_hash_str": "c169e73f4eb85de8bfa345d0b650b6ffb64054ebb57571e3d3a235b6ba616fe0", + "settled": null, + "settled_at": null, + "state": "CREATED", + "type": "incoming", + "value": 1000, + "metadata": null, + "destination_alias": null, + "destination_pubkey": null, + "first_route_hint_pubkey": null, + "first_route_hint_alias": null, + "qr_code_png": "https://getalby.com/api/invoices/6vaChhm17PqoJ5LJQi1THpUC.png", + "qr_code_svg": "https://getalby.com/api/invoices/6vaChhm17PqoJ5LJQi1THpUC.svg" +} +*/ diff --git a/Sources/AlbyKit/Models/InvoiceService/InvoiceHistoryUploadModel.swift b/Sources/AlbyKit/Models/InvoiceService/InvoiceHistoryUploadModel.swift new file mode 100644 index 0000000..0b2c9b3 --- /dev/null +++ b/Sources/AlbyKit/Models/InvoiceService/InvoiceHistoryUploadModel.swift @@ -0,0 +1,21 @@ + +public struct InvoiceHistoryUploadModel: Codable, Sendable { + + /// Filter invoices created before the given invoice identifier + public var before: String? + + /// Filter invoices created after the given invoice identifier + public var since: String? + + /// Filter invoices created before this Unix Timestamp in UTC (e.g. 1681992321) + public var createdAtLt: Int? + + /// Filter invoices created after this Unix Timestamp in UTC (e.g. 1681992321) + public var createdAtGt: Int? + + /// Page number (1 is the first page) + public var page: Int? + + /// Items per page (Default 25) + public var items: Int? +} diff --git a/Sources/AlbyKit/Models/InvoiceService/InvoiceUploadModel.swift b/Sources/AlbyKit/Models/InvoiceService/InvoiceUploadModel.swift new file mode 100644 index 0000000..b758f3a --- /dev/null +++ b/Sources/AlbyKit/Models/InvoiceService/InvoiceUploadModel.swift @@ -0,0 +1,45 @@ + +/// Invoice to be created +public struct InvoiceUploadModel: Codable, Sendable { + /// amount, must be a whole number in sats (millisats are not supported). + public var amount: Int64 + + /// Arbitrary text (included in the BOLT11 invoice) + public var description: String? + + /// Pass a hash of the description instead of the description (for private or long descriptions) + public var descriptionHash: String? + + /// currency of the invoice. Default is "btc" + public var currency: Currency? + + /// same as `description` field. + public var memo: String? + + /// Arbitrary text to save alongside the invoice (not included in the BOLT11 invoice) + public var comment: String? + + /// Arbitrary data to save alongside the invoice (not included in the BOLT11 invoice) +// public var metadata: [String : Any]? // TODO: figure out metadata's type + + /// Name of payer (not included in the BOLT11 invoice) + public var payerName: String? + + /// Email of payer (not included in the BOLT11 invoice) + public var payerEmail: String? + + /// Nostr or node pubkey of payer to store with the invoice (not included in the BOLT11 invoice) + public var payerPubkey: String? + + public init(amount: Int64, description: String? = nil, descriptionHash: String? = nil, currency: Currency? = nil, memo: String? = nil, comment: String? = nil, payerName: String? = nil, payerEmail: String? = nil, payerPubkey: String? = nil) { + self.amount = amount + self.description = description + self.descriptionHash = descriptionHash + self.currency = currency + self.memo = memo + self.comment = comment + self.payerName = payerName + self.payerEmail = payerEmail + self.payerPubkey = payerPubkey + } +} diff --git a/Sources/AlbyKit/Models/AccountService/Unit.swift b/Sources/AlbyKit/Models/Unit.swift similarity index 100% rename from Sources/AlbyKit/Models/AccountService/Unit.swift rename to Sources/AlbyKit/Models/Unit.swift diff --git a/Sources/AlbyKit/Services/InvoiceService.swift b/Sources/AlbyKit/Services/InvoiceService.swift new file mode 100644 index 0000000..8d1be50 --- /dev/null +++ b/Sources/AlbyKit/Services/InvoiceService.swift @@ -0,0 +1,110 @@ +import Foundation + +public struct InvoiceService { + private let router = NetworkRouter(decoder: .albyDecoder) + + /// Create an invoice + /// Scope needed: invoices:create + /// Creates a new invoice to receive lightning payments. + /// - returns a `CreatedInvoice` object + public func create(invoice: InvoiceUploadModel) async throws -> CreatedInvoice { + try await router.execute(.createInvoice(invoice)) + } + + /// Get incoming invoice history + /// Scope needed: invoices:read + /// Lists all settled incoming invoices, including boostagram and LNURL metadata. + /// - returns an array of `Invoice` objects + public func getIncomingInvoiceHistory(with uploadModel: InvoiceHistoryUploadModel) async throws -> [Invoice] { + try await router.execute(.incomingInvoiceHistory(uploadModel)) + } + + /// Get outgoing invoice history + /// Scope needed: transactions:read + /// Lists all settled outgoing invoices, including boostagrams information. + /// - returns an array of `Invoice` objects + public func getOutgoingInvoiceHistory(with uploadModel: InvoiceHistoryUploadModel) async throws -> [Invoice] { + try await router.execute(.outgoingInvoiceHistory(uploadModel)) + } + + /// Get all invoice history + /// Scope needed: invoices:read + /// Combination of incoming and outgoing invoice histories. Possible query parameters are the same as above. + /// - returns an array of `Invoice` objects + public func getAllInvoiceHistory(with uploadModel: InvoiceHistoryUploadModel) async throws -> [Invoice] { + try await router.execute(.allInvoiceHistory(uploadModel)) + } + + /// Get a specific invoice + /// Scope needed: invoices:read + /// Get details about specific invoice. Can be both incoming or outgoing. + /// ## Unsettled invoices can only be retrieved if they were created through the Alby API or Nostr Wallet Connect (using [https://nwc.getalby.com/](https://nwc.getalby.com/)). Unsettled Invoices created directly using the Lndhub API will return a 404. + /// - returns an `Invoice` object + public func getInvoice(withHash hash: String) async throws -> Invoice { + try await router.execute(.invoice(hash)) + } + + /// Decode an invoice + /// Decode an invoice. Will also add the alias of the receiving node & route hints (LSP's). + /// - returns a `Bolt11Invoice` objcet + public func decodeInvoice(bolt11: String) async throws -> Bolt11Invoice { + try await router.execute(.decodeBolt11(bolt11)) + } +} + +enum InvoiceAPI { + case createInvoice(InvoiceUploadModel) + case incomingInvoiceHistory(InvoiceHistoryUploadModel) + case outgoingInvoiceHistory(InvoiceHistoryUploadModel) + case allInvoiceHistory(InvoiceHistoryUploadModel) + case invoice(String) + case decodeBolt11(String) +} + +extension InvoiceAPI: EndpointType { + public var baseURL: URL { + guard let url = URL(string: prodAPI) else { fatalError("baseURL not configured.") } + return url + } + + var path: String { + switch self { + case .createInvoice: "/invoices" + case .incomingInvoiceHistory: "/invoices/incoming" + case .outgoingInvoiceHistory: "/invoices/outgoing" + case .allInvoiceHistory: "/invoices" + case .invoice(let paymentHash): "/invoices/\(paymentHash)" + case .decodeBolt11(let bolt11Invoice): "/decode/bolt11/\(bolt11Invoice)" + } + } + + var httpMethod: HTTPMethod { + switch self { + case .incomingInvoiceHistory, .outgoingInvoiceHistory, .allInvoiceHistory, .invoice, .decodeBolt11: .get + case .createInvoice: .post + } + } + + var task: HTTPTask { + switch self { + case .createInvoice(let invoice): + return .requestParameters(encoding: .jsonEncodableEncoding(encodable: invoice)) + case .incomingInvoiceHistory(let invoiceHistory), .outgoingInvoiceHistory(let invoiceHistory), .allInvoiceHistory(let invoiceHistory): + var parameters: Parameters = [:] + append(invoiceHistory.before, toParameters: ¶meters, withKey: "q[before]") + append(invoiceHistory.since, toParameters: ¶meters, withKey: "q[since]") + append(invoiceHistory.createdAtLt, toParameters: ¶meters, withKey: "q[created_at_lt]") + append(invoiceHistory.createdAtGt, toParameters: ¶meters, withKey: "q[created_at_gt]") + append(invoiceHistory.page, toParameters: ¶meters, withKey: "page") + append(invoiceHistory.items, toParameters: ¶meters, withKey: "items") + + return .requestParameters(encoding: .urlEncoding(parameters: parameters)) + case .invoice, .decodeBolt11: + return .request + } + } + + var headers: HTTPHeaders? { + nil + } +} diff --git a/Sources/AlbyKit/Services/ServiceHelper.swift b/Sources/AlbyKit/Services/ServiceHelper.swift new file mode 100644 index 0000000..ca38bec --- /dev/null +++ b/Sources/AlbyKit/Services/ServiceHelper.swift @@ -0,0 +1,14 @@ + +func append(_ any: Any?, toParameters parameters: inout Parameters, withKey key: String) { + guard let any else { return } + updateParamters(¶meters, with: (key, any)) +} + +func appendNil(toParameters paramters: inout Parameters, withKey key: String, forBool bool: Bool) { + guard bool else { return } + updateParamters(¶mters, with: (key, nil)) +} + +fileprivate func updateParamters(_ paramters: inout Parameters, with dataToAppend: (String, Any?)) { + paramters[dataToAppend.0] = dataToAppend.1 +}