diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 592ad2e041..4ae103d746 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -194,6 +194,7 @@ enum ChatCommand: ChatCmdProtocol { // badges case apiRedeemBadgeCode(userId: Int64, code: String) case apiGetBadgeState(userId: Int64) + case apiGetBadgeLedger(userId: Int64, badgePurchaseId: Int64) case apiAckBadgeAlert(userId: Int64, badgePurchaseId: Int64, alertKind: BadgeAlertKind, snooze: Bool, episode: String) // misc case showVersion @@ -419,6 +420,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiStandaloneFileInfo(link): return "/_download info \(link)" case let .apiRedeemBadgeCode(userId, code): return "/_redeem_badge_code \(userId) \(code)" case let .apiGetBadgeState(userId): return "/_badge state \(userId)" + case let .apiGetBadgeLedger(userId, badgePurchaseId): return "/_badge ledger \(userId) \(badgePurchaseId)" case let .apiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode): return "/_badge ack \(userId) \(badgePurchaseId) \(badgeAlertKindParam(alertKind)) \(onOff(snooze)) \(episode)" case .showVersion: return "/version" @@ -610,6 +612,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiStandaloneFileInfo: return "apiStandaloneFileInfo" case .apiRedeemBadgeCode: return "apiRedeemBadgeCode" case .apiGetBadgeState: return "apiGetBadgeState" + case .apiGetBadgeLedger: return "apiGetBadgeLedger" case .apiAckBadgeAlert: return "apiAckBadgeAlert" case .showVersion: return "showVersion" case .getAgentSubsTotal: return "getAgentSubsTotal" @@ -1048,6 +1051,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { // the full user, not UserRef: its profile carries the badge that setUserBadge just stored case badgeRedeemed(user: User, redeemedBadge: LocalBadge, newBadge: Bool, badgeState: BadgeState?) case badgeState(user: UserRef, badgeState: BadgeState?) + case badgeLedger(user: UserRef, badgeLedger: [StatementEntry]) var responseType: String { switch self { @@ -1101,6 +1105,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .appSettings: "appSettings" case .badgeRedeemed: "badgeRedeemed" case .badgeState: "badgeState" + case .badgeLedger: "badgeLedger" } } @@ -1156,6 +1161,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .appSettings(appSettings): return String(describing: appSettings) case let .badgeRedeemed(u, redeemedBadge, newBadge, badgeState): return withUser(u, "redeemedBadge: \(String(describing: redeemedBadge))\nnewBadge: \(newBadge)\nbadgeState: \(String(describing: badgeState))") case let .badgeState(u, badgeState): return withUser(u, String(describing: badgeState)) + case let .badgeLedger(u, badgeLedger): return withUser(u, String(describing: badgeLedger)) } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 31995a9d5c..d4de1205c7 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2229,6 +2229,12 @@ func apiGetBadgeStateSync(_ userId: Int64) throws -> BadgeState? { throw r.unexpected } +func apiGetBadgeLedger(_ userId: Int64, _ badgePurchaseId: Int64) async throws -> [StatementEntry] { + let r: ChatResponse2 = try await chatSendCmd(.apiGetBadgeLedger(userId: userId, badgePurchaseId: badgePurchaseId)) + if case let .badgeLedger(_, badgeLedger) = r { return badgeLedger } + throw r.unexpected +} + func apiAckBadgeAlert(_ userId: Int64, _ badgePurchaseId: Int64, _ alertKind: BadgeAlertKind, snooze: Bool, episode: String) async throws -> BadgeState? { let r: ChatResponse2 = try await chatSendCmd(.apiAckBadgeAlert(userId: userId, badgePurchaseId: badgePurchaseId, alertKind: alertKind, snooze: snooze, episode: episode)) if case let .badgeState(_, badgeState) = r { return badgeState } diff --git a/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift b/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift new file mode 100644 index 0000000000..a89bcb1299 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesLedgerView.swift @@ -0,0 +1,156 @@ +// +// BadgesLedgerView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 17.09.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesLedgerView: View { + @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + let badgeState: BadgeState + @State private var entries: [StatementEntry]? = nil + @State private var expanded: Set = [] + + var body: some View { + List { + if let entries { + Section { + if entries.isEmpty { + Text("No entries") + .foregroundColor(theme.colors.secondary) + } else { + ForEach(entries, id: \.entryId) { entry in + ledgerRow(entry) + } + } + } + } + } + .navigationTitle("Badge ledger") + .navigationBarTitleDisplayMode(.inline) + .modifier(ThemedBackground(grouped: true)) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShareSheet(items: [ledgerShareText()]) } label: { + Image(systemName: "square.and.arrow.up") + } + .disabled(entries?.isEmpty ?? true) + } + } + .onAppear(perform: loadLedger) + } + + @ViewBuilder private func ledgerRow(_ entry: StatementEntry) -> some View { + let isExpanded = expanded.contains(entry.entryId) + Button { + withAnimation { + if isExpanded { expanded.remove(entry.entryId) } else { expanded.insert(entry.entryId) } + } + } label: { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(entry.entryType.text) + Text(dateText(entry.createdAt)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + Text(changeText(entry)) + .foregroundStyle(.secondary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(theme.colors.secondary) + } + } + .foregroundColor(theme.colors.onBackground) + if isExpanded { + ForEach(entryFields(entry), id: \.0) { field in + infoRow(Text(field.0), field.1).padding(.leading, 24) + } + } + } + + private func changeText(_ entry: StatementEntry) -> String { + let n = entry.changeMonths + let months = abs(n) == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d month", comment: "time interval"), n) + : String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "time interval"), n) + return n > 0 ? "+" + months : months + } + + private func entryFields(_ entry: StatementEntry) -> [(String, String)] { + var fields = [ + (NSLocalizedString("Date", comment: "ledger entry field"), dateTimeText(entry.createdAt)), + (NSLocalizedString("Balance", comment: "ledger entry field"), "\(entry.balanceMonths)"), + (NSLocalizedString("Balance start", comment: "ledger entry field"), dateTimeText(entry.balanceStartTs)), + (NSLocalizedString("Anchor", comment: "ledger entry field"), dateTimeText(entry.balanceAnchorTs)), + (NSLocalizedString("Badge type", comment: "ledger entry field"), entry.balanceBadgeType.text) + ] + if let pausedSince = entry.wasPausedSince { + fields.append((NSLocalizedString("Paused since", comment: "ledger entry field"), dateTimeText(pausedSince))) + } + fields.append((NSLocalizedString("Entry ID", comment: "ledger entry field"), entry.entryId)) + if let payload = payloadField(entry.entryType) { + fields.append(payload) + } + return fields + } + + private func payloadField(_ entryType: StatementEntryType) -> (String, String)? { + switch entryType { + case let .credit(credit): + switch credit { + case let .payment(invoiceId): + return invoiceId.map { (NSLocalizedString("Invoice ID", comment: "ledger entry field"), $0) } + case let .charge(chargeId): + return (NSLocalizedString("Charge ID", comment: "ledger entry field"), chargeId) + case let .transferIn(fromPurchaseKey): + return (NSLocalizedString("From purchase key", comment: "ledger entry field"), fromPurchaseKey) + case .code, .support, .opening, .unknown: + return nil + } + case let .debit(debit): + switch debit { + case let .upgrade(toPurchaseKey), let .transferOut(toPurchaseKey): + return (NSLocalizedString("To purchase key", comment: "ledger entry field"), toPurchaseKey) + case .refund, .support, .badge, .lapse, .unknown: + return nil + } + } + } + + // the JSON as core sent it: English field names and ISO dates, for support + private func ledgerShareText() -> String { + let encoder = getJSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = (try? encoder.encode(entries ?? [])) ?? Data() + return String(decoding: data, as: UTF8.self) + } + + private func dateText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .none) + } + + private func dateTimeText(_ date: Date) -> String { + DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .short) + } + + private func loadLedger() { + guard let user = chatModel.currentUser else { return } + Task { + do { + let ledger = try await apiGetBadgeLedger(user.userId, badgeState.badgePurchaseId) + await MainActor.run { entries = ledger } + } catch let e { + logger.error("apiGetBadgeLedger error: \(responseError(e))") + await MainActor.run { + showErrorAlert(e, NSLocalizedString("Error", comment: "")) + } + } + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift index 07b6d7a3dc..59eeba7eac 100644 --- a/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift +++ b/apps/ios/Shared/Views/Badges/BadgesYourBadgeView.swift @@ -11,6 +11,8 @@ import SimpleXChat struct BadgesYourBadgeView: View { @EnvironmentObject var theme: AppTheme + @EnvironmentObject var chatModel: ChatModel + @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false let badgeState: BadgeState var showsAsSheet: Bool = false @@ -50,6 +52,24 @@ struct BadgesYourBadgeView: View { } } } + if developerTools { + Section(header: Text("Credential").foregroundColor(theme.colors.secondary)) { + if let badge = chatModel.currentUser?.profile.localBadge { + infoRow("Status", badge.status.rawValue) + infoRow("Expires", DateFormatter.localizedString(from: badge.badge.badgeExpiry, dateStyle: .medium, timeStyle: .short)) + } + infoRow("Months left", "\(badgeState.monthsLeft)") + infoRow("Purchase ID", "\(badgeState.badgePurchaseId)") + Button("Copy purchase key") { + UIPasteboard.general.string = badgeState.purchaseKey + } + NavigationLink { + BadgesLedgerView(badgeState: badgeState) + } label: { + Text("Badge ledger") + } + } + } } } .frame(maxHeight: .infinity) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 9d16c5c1f4..505552647b 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; }; 64466DCC29FFE3E800E3D48D /* MailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64466DCB29FFE3E800E3D48D /* MailView.swift */; }; 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */; }; + 644CC229305BFAE400D2A571 /* BadgesLedgerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */; }; 644EFFDE292BCD9D00525D5B /* ComposeVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */; }; 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */; }; 644EFFE2292D089800525D5B /* FramedCIVoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */; }; @@ -545,6 +546,7 @@ 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = ""; }; 64466DCB29FFE3E800E3D48D /* MailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailView.swift; sourceTree = ""; }; 6448BBB528FA9D56000D2AB9 /* GroupLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupLinkView.swift; sourceTree = ""; }; + 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesLedgerView.swift; sourceTree = ""; }; 644EFFDD292BCD9D00525D5B /* ComposeVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeVoiceView.swift; sourceTree = ""; }; 644EFFDF292CFD7F00525D5B /* CIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIVoiceView.swift; sourceTree = ""; }; 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FramedCIVoiceView.swift; sourceTree = ""; }; @@ -1212,6 +1214,7 @@ 64C03BF8302F423300072BDE /* Badges */ = { isa = PBXGroup; children = ( + 644CC228305BFAE400D2A571 /* BadgesLedgerView.swift */, 64EB8A9A3054347A0089FFDF /* BadgesView.swift */, 64C03BF0302F423300072BDE /* BadgesHowItWorksView.swift */, 64C03BF1302F423300072BDE /* BadgesPayView.swift */, @@ -1577,6 +1580,7 @@ 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, + 644CC229305BFAE400D2A571 /* BadgesLedgerView.swift in Sources */, 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c6e3bd5d74..ae3c8d662b 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -326,6 +326,7 @@ public struct LocalBadge: Codable, Hashable { // which outlives entitlement so the credential's window can cover a renewal. public struct BadgeState: Codable, Hashable { public var badgePurchaseId: Int64 + public var purchaseKey: String public var badgeType: BadgeType public var shown: Bool public var monthsLeft: Int @@ -337,6 +338,163 @@ public struct BadgeState: Codable, Hashable { public var paidThroughText: String { badgeDateText(paidThrough) } } +public struct StatementEntry: Codable, Hashable { + public var entryId: String + public var changeMonths: Int + public var balanceMonths: Int + public var balanceStartTs: Date + public var balanceAnchorTs: Date + public var balanceBadgeType: BadgeType + public var wasPausedSince: Date? + public var createdAt: Date + public var entryType: StatementEntryType +} + +public enum StatementEntryType: Codable, Hashable { + case credit(StatementCreditType) + case debit(StatementDebitType) + + enum CodingKeys: String, CodingKey { + case type + case credit + case debit + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "credit": self = .credit(try container.decode(StatementCreditType.self, forKey: .credit)) + case "debit": self = .debit(try container.decode(StatementDebitType.self, forKey: .debit)) + default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "unknown entry type \(type)") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .credit(c): + try container.encode("credit", forKey: .type) + try container.encode(c, forKey: .credit) + case let .debit(d): + try container.encode("debit", forKey: .type) + try container.encode(d, forKey: .debit) + } + } + + public var text: String { + switch self { + case let .credit(c): c.text + case let .debit(d): d.text + } + } +} + +// the service is deployed ahead of clients, so a type this version does not know keeps its tag +public enum StatementCreditType: Codable, Hashable { + case payment(invoiceId: String?) + case code + case charge(chargeId: String) + case support + case transferIn(fromPurchaseKey: String) + case opening + case unknown(type: String) + + enum CodingKeys: String, CodingKey { + case type + case invoiceId + case chargeId + case fromPurchaseKey + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "payment": self = .payment(invoiceId: try container.decodeIfPresent(String.self, forKey: .invoiceId)) + case "code": self = .code + case "charge": self = .charge(chargeId: try container.decode(String.self, forKey: .chargeId)) + case "support": self = .support + case "transferIn": self = .transferIn(fromPurchaseKey: try container.decode(String.self, forKey: .fromPurchaseKey)) + case "opening": self = .opening + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .type) + switch self { + case let .payment(invoiceId): try container.encodeIfPresent(invoiceId, forKey: .invoiceId) + case let .charge(chargeId): try container.encode(chargeId, forKey: .chargeId) + case let .transferIn(fromPurchaseKey): try container.encode(fromPurchaseKey, forKey: .fromPurchaseKey) + case .code, .support, .opening, .unknown: () + } + } + + public var text: String { + switch self { + case .payment: "payment" + case .code: "code" + case .charge: "charge" + case .support: "support" + case .transferIn: "transferIn" + case .opening: "opening" + case let .unknown(type): type + } + } +} + +public enum StatementDebitType: Codable, Hashable { + case refund + case upgrade(toPurchaseKey: String) + case transferOut(toPurchaseKey: String) + case support + case badge + case lapse + case unknown(type: String) + + enum CodingKeys: String, CodingKey { + case type + case toPurchaseKey + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + switch type { + case "refund": self = .refund + case "upgrade": self = .upgrade(toPurchaseKey: try container.decode(String.self, forKey: .toPurchaseKey)) + case "transferOut": self = .transferOut(toPurchaseKey: try container.decode(String.self, forKey: .toPurchaseKey)) + case "support": self = .support + case "badge": self = .badge + case "lapse": self = .lapse + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(text, forKey: .type) + switch self { + case let .upgrade(toPurchaseKey), let .transferOut(toPurchaseKey): try container.encode(toPurchaseKey, forKey: .toPurchaseKey) + case .refund, .support, .badge, .lapse, .unknown: () + } + } + + public var text: String { + switch self { + case .refund: "refund" + case .upgrade: "upgrade" + case .transferOut: "transferOut" + case .support: "support" + case .badge: "badge" + case .lapse: "lapse" + case let .unknown(type): type + } + } +} + public struct BadgeAlert: Codable, Hashable { public var kind: BadgeAlertKind public var episode: String diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index b5c0f23c60..5bbb41e4dd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -2225,6 +2225,7 @@ data class LocalBadge( @Serializable data class BadgeState( val badgePurchaseId: Long, + val purchaseKey: String, val badgeType: BadgeType, val shown: Boolean, val monthsLeft: Int, @@ -2236,6 +2237,137 @@ data class BadgeState( val paidThroughText: String get() = badgeDateText(paidThrough) } +@Serializable +data class StatementEntry( + val entryId: String, + val changeMonths: Int, + val balanceMonths: Int, + val balanceStartTs: Instant, + val balanceAnchorTs: Instant, + val balanceBadgeType: BadgeType, + val wasPausedSince: Instant? = null, + val createdAt: Instant, + val entryType: StatementEntryType +) + +@Serializable +sealed class StatementEntryType { + @Serializable @SerialName("credit") data class Credit(val credit: StatementCreditType): StatementEntryType() + @Serializable @SerialName("debit") data class Debit(val debit: StatementDebitType): StatementEntryType() + + val text: String + get() = when (this) { + is Credit -> credit.text + is Debit -> debit.text + } +} + +// the service is deployed ahead of clients, so a type this version does not know keeps its tag +@Serializable(with = StatementCreditTypeSerializer::class) +sealed class StatementCreditType { + @Serializable data class Payment(val invoiceId: String? = null): StatementCreditType() + object Code: StatementCreditType() + @Serializable data class Charge(val chargeId: String): StatementCreditType() + object Support: StatementCreditType() + @Serializable data class TransferIn(val fromPurchaseKey: String): StatementCreditType() + object Opening: StatementCreditType() + data class Unknown(val type: String): StatementCreditType() + + val text: String + get() = when (this) { + is Payment -> "payment" + is Code -> "code" + is Charge -> "charge" + is Support -> "support" + is TransferIn -> "transferIn" + is Opening -> "opening" + is Unknown -> type + } +} + +object StatementCreditTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("StatementCreditType") + + override fun deserialize(decoder: Decoder): StatementCreditType { + require(decoder is JsonDecoder) + val json = decoder.decodeJsonElement().jsonObject + return when (val type = json["type"]?.jsonPrimitive?.content ?: "") { + "payment" -> decoder.json.decodeFromJsonElement(json) + "code" -> StatementCreditType.Code + "charge" -> decoder.json.decodeFromJsonElement(json) + "support" -> StatementCreditType.Support + "transferIn" -> decoder.json.decodeFromJsonElement(json) + "opening" -> StatementCreditType.Opening + else -> StatementCreditType.Unknown(type) + } + } + + override fun serialize(encoder: Encoder, value: StatementCreditType) { + require(encoder is JsonEncoder) + encoder.encodeJsonElement(buildJsonObject { + put("type", value.text) + when (value) { + is StatementCreditType.Payment -> value.invoiceId?.let { put("invoiceId", it) } + is StatementCreditType.Charge -> put("chargeId", value.chargeId) + is StatementCreditType.TransferIn -> put("fromPurchaseKey", value.fromPurchaseKey) + is StatementCreditType.Code, is StatementCreditType.Support, is StatementCreditType.Opening, is StatementCreditType.Unknown -> {} + } + }) + } +} + +@Serializable(with = StatementDebitTypeSerializer::class) +sealed class StatementDebitType { + object Refund: StatementDebitType() + @Serializable data class Upgrade(val toPurchaseKey: String): StatementDebitType() + @Serializable data class TransferOut(val toPurchaseKey: String): StatementDebitType() + object Support: StatementDebitType() + object Badge: StatementDebitType() + object Lapse: StatementDebitType() + data class Unknown(val type: String): StatementDebitType() + + val text: String + get() = when (this) { + is Refund -> "refund" + is Upgrade -> "upgrade" + is TransferOut -> "transferOut" + is Support -> "support" + is Badge -> "badge" + is Lapse -> "lapse" + is Unknown -> type + } +} + +object StatementDebitTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("StatementDebitType") + + override fun deserialize(decoder: Decoder): StatementDebitType { + require(decoder is JsonDecoder) + val json = decoder.decodeJsonElement().jsonObject + return when (val type = json["type"]?.jsonPrimitive?.content ?: "") { + "refund" -> StatementDebitType.Refund + "upgrade" -> decoder.json.decodeFromJsonElement(json) + "transferOut" -> decoder.json.decodeFromJsonElement(json) + "support" -> StatementDebitType.Support + "badge" -> StatementDebitType.Badge + "lapse" -> StatementDebitType.Lapse + else -> StatementDebitType.Unknown(type) + } + } + + override fun serialize(encoder: Encoder, value: StatementDebitType) { + require(encoder is JsonEncoder) + encoder.encodeJsonElement(buildJsonObject { + put("type", value.text) + when (value) { + is StatementDebitType.Upgrade -> put("toPurchaseKey", value.toPurchaseKey) + is StatementDebitType.TransferOut -> put("toPurchaseKey", value.toPurchaseKey) + is StatementDebitType.Refund, is StatementDebitType.Support, is StatementDebitType.Badge, is StatementDebitType.Lapse, is StatementDebitType.Unknown -> {} + } + }) + } +} + @Serializable data class BadgeAlert( val kind: BadgeAlertKind, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 8b4e4fc315..966a931208 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -606,6 +606,12 @@ object ChatController { throw Exception("apiGetBadgeState: unexpected ${r.responseType}") } + suspend fun apiGetBadgeLedger(rh: Long?, userId: Long, badgePurchaseId: Long): List { + val r = sendCmd(rh, CC.ApiGetBadgeLedger(userId, badgePurchaseId)) + if (r is API.Result && r.res is CR.BadgeLedger) return r.res.badgeLedger + throw Exception("apiGetBadgeLedger: unexpected ${r.responseType}") + } + suspend fun apiAckBadgeAlert(rh: Long?, userId: Long, badgePurchaseId: Long, alertKind: BadgeAlertKind, snooze: Boolean, episode: String): BadgeState? { val r = sendCmd(rh, CC.ApiAckBadgeAlert(userId, badgePurchaseId, alertKind, snooze, episode)) if (r is API.Result && r.res is CR.BadgeStateR) return r.res.badgeState @@ -4057,6 +4063,7 @@ sealed class CC { // badges class ApiRedeemBadgeCode(val userId: Long, val code: String): CC() class ApiGetBadgeState(val userId: Long): CC() + class ApiGetBadgeLedger(val userId: Long, val badgePurchaseId: Long): CC() class ApiAckBadgeAlert(val userId: Long, val badgePurchaseId: Long, val alertKind: BadgeAlertKind, val snooze: Boolean, val episode: String): CC() // misc class ShowVersion(): CC() @@ -4281,6 +4288,7 @@ sealed class CC { is ApiStandaloneFileInfo -> "/_download info $url" is ApiRedeemBadgeCode -> "/_redeem_badge_code $userId $code" is ApiGetBadgeState -> "/_badge state $userId" + is ApiGetBadgeLedger -> "/_badge ledger $userId $badgePurchaseId" is ApiAckBadgeAlert -> "/_badge ack $userId $badgePurchaseId ${badgeAlertKindParam(alertKind)} ${onOff(snooze)} $episode" is ShowVersion -> "/version" is ResetAgentServersStats -> "/reset servers stats" @@ -4464,6 +4472,7 @@ sealed class CC { is ApiStandaloneFileInfo -> "apiStandaloneFileInfo" is ApiRedeemBadgeCode -> "apiRedeemBadgeCode" is ApiGetBadgeState -> "apiGetBadgeState" + is ApiGetBadgeLedger -> "apiGetBadgeLedger" is ApiAckBadgeAlert -> "apiAckBadgeAlert" is ShowVersion -> "showVersion" is ResetAgentServersStats -> "resetAgentServersStats" @@ -6854,6 +6863,7 @@ sealed class CR { // the full user, not UserRef: its profile carries the badge that setUserBadge just stored @Serializable @SerialName("badgeRedeemed") class BadgeRedeemed(val user: User, val redeemedBadge: LocalBadge, val newBadge: Boolean, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeState") class BadgeStateR(val user: UserRef, val badgeState: BadgeState?): CR() + @Serializable @SerialName("badgeLedger") class BadgeLedger(val user: UserRef, val badgeLedger: List): CR() @Serializable @SerialName("badgeChanged") class BadgeChanged(val user: User, val badgeState: BadgeState?): CR() @Serializable @SerialName("badgeAlert") class BadgeAlertR(val user: UserRef, val badgeAlert: BadgeAlert): CR() // general @@ -7044,6 +7054,7 @@ sealed class CR { is AppSettingsR -> "appSettings" is BadgeRedeemed -> "badgeRedeemed" is BadgeStateR -> "badgeState" + is BadgeLedger -> "badgeLedger" is BadgeChanged -> "badgeChanged" is BadgeAlertR -> "badgeAlert" is Response -> "* $type" @@ -7251,6 +7262,7 @@ sealed class CR { is AppSettingsR -> json.encodeToString(appSettings) is BadgeRedeemed -> withUser(user, "redeemedBadge: ${json.encodeToString(redeemedBadge)}\nnewBadge: $newBadge\nbadgeState: ${json.encodeToString(badgeState)}") is BadgeStateR -> withUser(user, json.encodeToString(badgeState)) + is BadgeLedger -> withUser(user, json.encodeToString(badgeLedger)) is BadgeChanged -> withUser(user, json.encodeToString(badgeState)) is BadgeAlertR -> withUser(user, json.encodeToString(badgeAlert)) is Response -> json diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt new file mode 100644 index 0000000000..2783727621 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesLedgerView.kt @@ -0,0 +1,158 @@ +package chat.simplex.common.views.badges + +import InfoRow +import SectionBottomSpacer +import SectionItemView +import SectionView +import itemHPadding +import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import chat.simplex.common.model.BadgeState +import chat.simplex.common.model.StatementCreditType +import chat.simplex.common.model.StatementDebitType +import chat.simplex.common.model.StatementEntry +import chat.simplex.common.model.StatementEntryType +import chat.simplex.common.model.json +import chat.simplex.common.model.localDate +import chat.simplex.common.model.localTimestamp +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.shareText +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.AppBarTitle +import chat.simplex.common.views.helpers.ModalView +import chat.simplex.common.views.helpers.ShareButton +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.common.views.helpers.withBGApi +import chat.simplex.res.MR +import kotlin.math.abs + +@Composable +fun BadgesLedgerView(badgeState: BadgeState, close: () -> Unit) { + val entries = remember { mutableStateOf?>(null) } + val clipboard = LocalClipboardManager.current + + LaunchedEffect(Unit) { + val user = chatModel.currentUser.value ?: return@LaunchedEffect + withBGApi { + try { + val ledger = chatModel.controller.apiGetBadgeLedger(chatModel.remoteHostId(), user.userId, badgeState.badgePurchaseId) + withContext(Dispatchers.Main) { entries.value = ledger } + } catch (e: Exception) { + Log.e(TAG, "apiGetBadgeLedger: ${e.message}") + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), e.message) + } + } + } + + ModalView( + close, + cardScreen = true, + endButtons = { + val loaded = entries.value + if (!loaded.isNullOrEmpty()) { + ShareButton { clipboard.shareText(ledgerShareText(loaded)) } + } + } + ) { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.badges_ledger)) + val loaded = entries.value + if (loaded != null) { + SectionView { + if (loaded.isEmpty()) { + SectionItemView { + Text(stringResource(MR.strings.badges_ledger_no_entries), color = MaterialTheme.colors.secondary) + } + } else { + loaded.forEach { LedgerRow(it) } + } + } + } + SectionBottomSpacer() + } + } +} + +@Composable +private fun LedgerRow(entry: StatementEntry) { + val expanded = remember(entry.entryId) { mutableStateOf(false) } + SectionItemView(click = { expanded.value = !expanded.value }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(entry.entryType.text) + Text(localDate(entry.createdAt), color = MaterialTheme.colors.secondary, fontSize = 12.sp) + } + Text(changeText(entry), color = MaterialTheme.colors.secondary) + Icon( + painterResource(if (expanded.value) MR.images.ic_chevron_up else MR.images.ic_chevron_down), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } + } + if (expanded.value) { + val indented = PaddingValues(start = 24.dp + itemHPadding, end = itemHPadding) + entryFields(entry).forEach { (label, value) -> InfoRow(label, value, padding = indented) } + } +} + +private fun changeText(entry: StatementEntry): String { + val n = entry.changeMonths + val months = String.format(generalGetString(if (abs(n) == 1) MR.strings.ttl_month else MR.strings.ttl_months), n) + return if (n > 0) "+$months" else months +} + +private fun entryFields(entry: StatementEntry): List> { + val fields = mutableListOf( + generalGetString(MR.strings.badges_ledger_date) to localTimestamp(entry.createdAt), + generalGetString(MR.strings.badges_ledger_balance) to entry.balanceMonths.toString(), + generalGetString(MR.strings.badges_ledger_balance_start) to localTimestamp(entry.balanceStartTs), + generalGetString(MR.strings.badges_ledger_anchor) to localTimestamp(entry.balanceAnchorTs), + generalGetString(MR.strings.badges_ledger_badge_type) to entry.balanceBadgeType.text, + ) + val pausedSince = entry.wasPausedSince + if (pausedSince != null) { + fields.add(generalGetString(MR.strings.badges_ledger_paused_since) to localTimestamp(pausedSince)) + } + fields.add(generalGetString(MR.strings.badges_ledger_entry_id) to entry.entryId) + payloadField(entry.entryType)?.let { fields.add(it) } + return fields +} + +private fun payloadField(entryType: StatementEntryType): Pair? = when (entryType) { + is StatementEntryType.Credit -> when (val credit = entryType.credit) { + is StatementCreditType.Payment -> credit.invoiceId?.let { generalGetString(MR.strings.badges_ledger_invoice_id) to it } + is StatementCreditType.Charge -> generalGetString(MR.strings.badges_ledger_charge_id) to credit.chargeId + is StatementCreditType.TransferIn -> generalGetString(MR.strings.badges_ledger_from_purchase_key) to credit.fromPurchaseKey + is StatementCreditType.Code, is StatementCreditType.Support, is StatementCreditType.Opening, is StatementCreditType.Unknown -> null + } + is StatementEntryType.Debit -> when (val debit = entryType.debit) { + is StatementDebitType.Upgrade -> generalGetString(MR.strings.badges_ledger_to_purchase_key) to debit.toPurchaseKey + is StatementDebitType.TransferOut -> generalGetString(MR.strings.badges_ledger_to_purchase_key) to debit.toPurchaseKey + is StatementDebitType.Refund, is StatementDebitType.Support, is StatementDebitType.Badge, is StatementDebitType.Lapse, is StatementDebitType.Unknown -> null + } +} + +// the JSON as core sent it: English field names and ISO dates, for support +private fun ledgerShareText(entries: List): String = json.encodeToString(entries) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt index c17559d5b0..035eb64846 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourBadgeView.kt @@ -1,5 +1,7 @@ package chat.simplex.common.views.badges +import InfoRow +import SectionItemView import SectionSpacer import SectionTextFooter import SectionView @@ -11,12 +13,17 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.BadgeState +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.localTimestamp import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.DEFAULT_PADDING import chat.simplex.common.views.helpers.AppBarTitle import chat.simplex.common.views.helpers.ModalManager @@ -47,6 +54,25 @@ fun BadgesYourBadgeView(badgeState: BadgeState) { ) } SectionSpacer() + if (appPrefs.developerTools.get()) { + val clipboard = LocalClipboardManager.current + SectionView(stringResource(MR.strings.badges_credential)) { + val badge = chatModel.currentUser.value?.profile?.localBadge + if (badge != null) { + InfoRow(stringResource(MR.strings.badges_credential_status), badge.status.name) + InfoRow(stringResource(MR.strings.badges_credential_expires), localTimestamp(badge.badge.badgeExpiry)) + } + InfoRow(stringResource(MR.strings.badges_credential_months_left), badgeState.monthsLeft.toString()) + InfoRow(stringResource(MR.strings.badges_credential_purchase_id), badgeState.badgePurchaseId.toString()) + SectionItemView({ clipboard.setText(AnnotatedString(badgeState.purchaseKey)) }) { + Text(stringResource(MR.strings.badges_copy_purchase_key), color = MaterialTheme.colors.primary) + } + SectionItemView({ ModalManager.start.showCustomModal { close -> BadgesLedgerView(badgeState, close) } }) { + Text(stringResource(MR.strings.badges_ledger)) + } + } + SectionSpacer() + } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index f2f6555b78..e310834fda 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3205,6 +3205,25 @@ Your support ended on %1$s. Remind me later Dismiss + Credential + Status + Expires + Months left + Purchase ID + Copy purchase key + Badge ledger + No entries + Date + Balance + Balance start + Anchor + Badge type + Paused since + Entry ID + Invoice ID + Charge ID + From purchase key + To purchase key Supporter perks Supporter badge ❤️ Help keep the network running — send files up to 2 GB. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index c389605f31..eb4804fa45 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -378,6 +378,7 @@ undocumentedCommands = "APIExportArchive", "APIForwardChatItems", "APIGetAppSettings", + "APIGetBadgeLedger", "APIGetBadgeState", "APIGetCallInvitations", "APIGetChat", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 5d01c03a39..18392ae1b5 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -134,6 +134,7 @@ undocumentedResponses = "CRAppSettings", "CRArchiveExported", "CRArchiveImported", + "CRBadgeLedger", "CRBadgeRedeemed", "CRBadgeState", "CRBroadcastSent", diff --git a/src/Simplex/Chat/Badges/Types.hs b/src/Simplex/Chat/Badges/Types.hs index 4c81cb50ac..cc205458c7 100644 --- a/src/Simplex/Chat/Badges/Types.hs +++ b/src/Simplex/Chat/Badges/Types.hs @@ -215,10 +215,11 @@ data BadgeAlertPrice = BadgeAlertPrice } deriving (Show) --- | The user's badge as the badge surfaces render it. The purchase keys are deliberately absent: --- this travels to the UI and over remote control, and they are secrets that stay in core. +-- | The user's badge as the badge surfaces render it. The private purchase key is deliberately +-- absent: this travels to the UI and over remote control, and it is a secret that stays in core. data BadgeState = BadgeState { badgePurchaseId :: Int64, + purchaseKey :: C.PublicKeyEd25519, -- the purchase's identifier on the service badgeType :: BadgeType, shown :: BoolDef, monthsLeft :: Int, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6206e8ae50..a5f46faee2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -84,7 +84,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (HostMode (..), SMPProxyFallback (..), SMPProxyMode (..), SMPWebPortServers (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Chat.Badges (BadgeCredential, FileSizeLimits, LocalBadge) -import Simplex.Chat.Badges.Service (BadgeServiceErrorCode) +import Simplex.Chat.Badges.Service (BadgeServiceErrorCode, StatementEntry) import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind, BadgeState (..)) import Simplex.Messaging.Crypto.BBS (BBSPublicKey) import Simplex.Messaging.Crypto.File (CryptoFile (..)) @@ -660,6 +660,7 @@ data ChatCommand | AddBadge BadgeCredential -- attach an issued badge credential (testing; credential from `simplex-chat badge sign`) | APIRedeemBadgeCode {userId :: UserId, code :: Text} -- redeem a badge code with the configured badge service | APIGetBadgeState {userId :: UserId} -- the user's badges, their balances and any current alert + | APIGetBadgeLedger {userId :: UserId, badgePurchaseId :: Int64} -- the purchase's ledger, oldest first -- episode is last because it is free text: it is the value that makes one occurrence of an -- alert distinct from the next, and the app returns whatever it was given | APIAckBadgeAlert {userId :: UserId, badgePurchaseId :: Int64, alertKind :: BadgeAlertKind, snooze :: Bool, episode :: Text} @@ -869,6 +870,7 @@ data ChatResponse | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} + | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c3d5624cb1..49df03a6d2 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3567,6 +3567,8 @@ processChatCommand cxt nm = \case -- the read also signals the worker, whose results follow as CEvtBadgeChanged lift $ startBadgeWork user CRBadgeState user <$> getUserBadgeState user + APIGetBadgeLedger userId badgePurchaseId -> withUserId userId $ \user -> + CRBadgeLedger user <$> withStore' (\db -> getBadgeLedger db user badgePurchaseId) APIAckBadgeAlert userId badgePurchaseId alertKind snooze episode -> withUserId userId $ \user -> do now <- badgeNow let snoozeUntil = if snooze then Just (addUTCTime nominalDay now) else Nothing @@ -5417,9 +5419,10 @@ getUserBadgeState user = do Just p@UserBadgePurchase {badgePurchaseId} -> fmap (badgeStateOf now p) <$> withStore' (`getBadgeLedgerLastEntry` badgePurchaseId) where - badgeStateOf now p@UserBadgePurchase {badgePurchaseId, badgeType, shown} balance = + badgeStateOf now p@UserBadgePurchase {badgePurchaseId, purchaseKey, badgeType, shown} balance = BadgeState { badgePurchaseId, + purchaseKey, badgeType, shown = BoolDef shown, monthsLeft = balanceMonths balance, @@ -6039,6 +6042,7 @@ chatCommandP = "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_redeem_badge_code " *> (APIRedeemBadgeCode <$> A.decimal <* A.space <*> textP), "/_badge state " *> (APIGetBadgeState <$> A.decimal), + "/_badge ledger " *> (APIGetBadgeLedger <$> A.decimal <* A.space <*> A.decimal), "/_badge ack " *> (APIAckBadgeAlert <$> A.decimal <* A.space <*> A.decimal <* A.space <*> badgeAlertKindP <* A.space <*> onOffP <* A.space <*> textP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), diff --git a/src/Simplex/Chat/Store/Badges.hs b/src/Simplex/Chat/Store/Badges.hs index c17bd5f360..d581150df8 100644 --- a/src/Simplex/Chat/Store/Badges.hs +++ b/src/Simplex/Chat/Store/Badges.hs @@ -4,6 +4,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TypeOperators #-} module Simplex.Chat.Store.Badges ( BadgeCodeRedemption (..), @@ -22,6 +23,7 @@ module Simplex.Chat.Store.Badges getLatestIssuedCredential, storeBadgeStatement, getBadgeLedgerLastEntry, + getBadgeLedger, getBadgeLedgerEntryId, ) where @@ -31,7 +33,7 @@ import Crypto.Random (ChaChaDRG) import qualified Data.Aeson as J import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) -import Data.Maybe (isJust) +import Data.Maybe (isJust, mapMaybe) import Data.Text (Text) import Data.Time.Clock (UTCTime) import Simplex.Chat.Badges @@ -306,7 +308,7 @@ storeBadgeStatement db badgePurchaseId badgeType tip entries now = -- | The balance is the last row; nothing derives it by summing the history. getBadgeLedgerLastEntry :: DB.Connection -> Int64 -> IO (Maybe StatementEntry) getBadgeLedgerLastEntry db badgePurchaseId = - maybeFirstRow' Nothing toEntry $ + maybeFirstRow' Nothing toStatementEntry $ DB.query db [sql| @@ -318,10 +320,27 @@ getBadgeLedgerLastEntry db badgePurchaseId = LIMIT 1 |] (Only badgePurchaseId) - where - toEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = - (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) - <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ + +-- | Oldest first. A row whose type this version cannot rebuild is left out, as it is from the tip. +getBadgeLedger :: DB.Connection -> User -> Int64 -> IO [StatementEntry] +getBadgeLedger db User {userId} badgePurchaseId = + mapMaybe toStatementEntry + <$> DB.query + db + [sql| + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.was_paused_since, l.service_created_at, l.entry_type, l.entry_credit_type, l.entry_debit_type, l.entry_type_value + FROM badge_ledger l + JOIN badge_purchases p ON p.badge_purchase_id = l.badge_purchase_id + WHERE l.badge_purchase_id = ? AND p.user_id = ? + ORDER BY l.entry_id + |] + (badgePurchaseId, userId) + +toStatementEntry :: (Text, Int, Int, UTCTime, UTCTime, BadgeType) :. (Maybe UTCTime, UTCTime, Text, Maybe Text, Maybe Text, Maybe Text) -> Maybe StatementEntry +toStatementEntry ((entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType) :. (wasPausedSince, createdAt, entryType_, credit_, debit_, value_)) = + (\entryType -> StatementEntry {entryId, changeMonths, balanceMonths, balanceStartTs, balanceAnchorTs, balanceBadgeType, wasPausedSince, createdAt, entryType}) + <$> maybe (entryTypeFromColumns entryType_ credit_ debit_) (entryTypeFromValue entryType_) value_ -- | Decodes the stored JSON rather than rebuilding from the tag, so a version that has since -- learnt the type reads it with its fields, and one that has not still gets it back verbatim. diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 10cf2b93c2..fd518f855e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4024,6 +4024,18 @@ Query: Plan: SCAN group_members +Query: + SELECT l.entry_uuid, l.change_months, l.balance_months, l.balance_start_ts, l.balance_anchor_ts, l.balance_badge_type, + l.was_paused_since, l.service_created_at, l.entry_type, l.entry_credit_type, l.entry_debit_type, l.entry_type_value + FROM badge_ledger l + JOIN badge_purchases p ON p.badge_purchase_id = l.badge_purchase_id + WHERE l.badge_purchase_id = ? AND p.user_id = ? + ORDER BY l.entry_id + +Plan: +SEARCH p USING COVERING INDEX idx_badge_purchases_user (user_id=? AND rowid=?) +SEARCH l USING INDEX idx_badge_ledger_purchase (badge_purchase_id=?) + Query: SELECT m.group_member_id FROM group_members m @@ -7679,6 +7691,10 @@ Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? A Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT service_created_at, balance_start_ts FROM badge_ledger ORDER BY entry_id +Plan: +SCAN badge_ledger + Query: SELECT shared_msg_id FROM chat_items WHERE shared_msg_id IS NOT NULL ORDER BY chat_item_id DESC LIMIT 1 Plan: SCAN chat_items diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 007dc8af0b..66158303f9 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -44,6 +44,8 @@ import Simplex.Chat.Help import Simplex.Chat.Library.Commands (badgeServiceErrorText, maxImageSize) import Simplex.Chat.Markdown import Simplex.Chat.Badges (BadgeInfo (..), BadgeStatus (..), BadgeType (..), LocalBadge, localBadgeInfo, localBadgeStatus) +import Simplex.Chat.Badges.Ledger (creditTypeTag, debitTypeTag) +import Simplex.Chat.Badges.Service (StatementEntry (..), StatementEntryType (..)) import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeState (..)) import Simplex.Chat.Messages hiding (NewChatItem (..)) import Simplex.Chat.Messages.CIContent @@ -192,6 +194,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te -- the badge is only shown when it is the one now on the profile; a replayed code's badge may not be CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] CRBadgeState u st -> ttyUser u $ viewUserBadgeState st + CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results @@ -1854,6 +1857,17 @@ viewUserBadgeState = maybe [] viewBadge viewBadgeAlert :: BadgeAlert -> [StyledString] viewBadgeAlert BadgeAlert {kind, date} = [plain $ "badge alert: " <> textEncode kind <> " " <> day date] +viewBadgeLedger :: [StatementEntry] -> [StyledString] +viewBadgeLedger [] = ["no ledger entries"] +viewBadgeLedger entries = map viewEntry entries + where + viewEntry StatementEntry {createdAt, entryType, changeMonths, balanceMonths, balanceStartTs} = + plain $ day createdAt <> " " <> entryKind entryType <> " " <> withSign changeMonths <> " -> " <> tshow balanceMonths <> ", from " <> day balanceStartTs + entryKind = \case + SECredit c -> creditTypeTag c + SEDebit d -> debitTypeTag d + withSign n = (if n >= 0 then "+" else "") <> tshow n + day :: UTCTime -> Text day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d" diff --git a/tests/Bots/BadgeServiceTests.hs b/tests/Bots/BadgeServiceTests.hs index 640d0ba533..4897722683 100644 --- a/tests/Bots/BadgeServiceTests.hs +++ b/tests/Bots/BadgeServiceTests.hs @@ -17,7 +17,7 @@ import ChatTests.DBUtils import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Concurrent.STM (atomically, readTMVar) -import Control.Monad (void, when) +import Control.Monad (forM_, void, when) import Control.Exception (finally) import qualified Data.Aeson as J import qualified Data.ByteString.Char8 as B @@ -32,6 +32,7 @@ import System.Timeout (timeout) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime, nominalDay) +import Data.Time.Format (defaultTimeLocale, formatTime) import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey, BadgeType (..), generateMasterKey) import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, formatBadgeCode, parseBadgeCode, randomBadgeCode) import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOfMondayAfter) @@ -504,6 +505,12 @@ ledgerRows ChatController {chatStore} table = <> table <> " ORDER BY entry_id" +-- the two dates the CLI prints for each row +ledgerTimes :: ChatController -> IO [(UTCTime, UTCTime)] +ledgerTimes ChatController {chatStore} = + withTransaction chatStore $ \db -> + DB.query_ db "SELECT service_created_at, balance_start_ts FROM badge_ledger ORDER BY entry_id" + -- | The client's verdict on each row, in ledger order. The service has no such column: it computes -- the rows rather than checking what someone else computed. balanceChecks :: ChatController -> IO [Maybe Bool] @@ -539,6 +546,18 @@ testClientReplicatesLedger ps = -- nor a second issuance for the one month issued: the replay names a month already stored expiries <- issuedExpiries (chatController alice) length expiries `shouldBe` 1 + -- the CLI lists the rows oldest first, with the dates they carry + times <- ledgerTimes (chatController alice) + alice ##> "/_badge ledger 1 1" + forM_ (zip times [("code", "+3", "3"), ("badge", "-1", "2")]) $ \((createdAt, from), (kind, change, balance)) -> + alice <## (day createdAt <> " " <> kind <> " " <> change <> " -> " <> balance <> ", from " <> day from) + -- and nothing for a purchase that is another profile's + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_badge ledger 2 1" + alice <## "no ledger entries" + where + day = formatTime defaultTimeLocale "%Y-%m-%d" -- the balance start of the last row, which is when the next month falls due dueAtOf :: [ReplicatedRow] -> UTCTime