Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/Payments/TSPaymentModel.swift
1 views
//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation
public import GRDB
public import LibSignalClient

// We store payment records separately from interactions.
//
// * Payment records might correspond to transfers to/from exchanges,
//   without an associated interaction.
// * Interactions might be deleted, but we need to maintain records of
//   all payments.
public final class TSPaymentModel: NSObject, SDSCodableModel, Decodable {
    public static let databaseTableName: String = "model_TSPaymentModel"
    private static let recordType: SDSRecordType = .paymentModel

    public var id: Int64?
    public let uniqueId: String

    // Incoming, outgoing, etc.
    //
    // This is inferred from paymentState.
    public let paymentType: TSPaymentType

    public private(set) var paymentState: TSPaymentState

    // This property only applies if paymentState is .incomingFailure
    // or .outgoingFailure.
    public private(set) var paymentFailure: TSPaymentFailure

    // Might not be set for unverified incoming payments.
    public private(set) var paymentAmount: TSPaymentAmount?

    public private(set) var createdTimestamp: UInt64

    // Optional. The address of the sender/recipient, if any.
    //
    // We should not treat this value as valid for unverified incoming payments.
    public private(set) var addressUuidString: String?

    // Optional. Used to construct outgoing notifications.
    //           This should only be set for outgoing payments from the device that
    //           submitted the payment.
    //           We should clear this as soon as sending notification succeeds.
    public private(set) var requestUuidString: String?

    public private(set) var memoMessage: String?

    public private(set) var isUnread: Bool

    // Optional. If set, the unique id of the interaction displayed in chat
    // for this payment. If nil, safe to assume no interaction exists and one
    // can be created.
    public private(set) var interactionUniqueId: String?

    // This only applies to mobilecoin.
    public private(set) var mobileCoin: MobileCoinPayment?

    // This only applies to mobilecoin.
    // Used by PaymentFinder.
    // This value is zero if not set.
    public private(set) var mcLedgerBlockIndex: UInt64

    // Only set for outgoing mobileCoin payments.
    // This only applies to mobilecoin.
    // Used by PaymentFinder.
    public private(set) var mcTransactionData: Data?

    // This only applies to mobilecoin.
    // Used by PaymentFinder.
    public private(set) var mcReceiptData: Data?

    public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
        case id
        case recordType
        case uniqueId
        case addressUuidString
        case createdTimestamp
        case isUnread
        case mcLedgerBlockIndex
        case mcReceiptData
        case mcTransactionData
        case memoMessage
        case mobileCoin
        case paymentAmount
        case paymentFailure
        case paymentState
        case paymentType
        case requestUuidString
        case interactionUniqueId
    }

    public init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decodeIfPresent(Int64.self, forKey: .id)
        self.uniqueId = try container.decode(String.self, forKey: .uniqueId)
        self.paymentType = try container.decode(TSPaymentType.self, forKey: .paymentType)
        self.paymentState = try container.decode(TSPaymentState.self, forKey: .paymentState)
        self.paymentFailure = try container.decode(TSPaymentFailure.self, forKey: .paymentFailure)
        let paymentAmountData = try container.decodeIfPresent(Data.self, forKey: .paymentAmount)
        self.paymentAmount = try paymentAmountData.map { try LegacySDSSerializer().deserializeLegacySDSData($0, ofClass: TSPaymentAmount.self) }
        self.createdTimestamp = try container.decode(UInt64.self, forKey: .createdTimestamp)
        self.addressUuidString = try container.decodeIfPresent(String.self, forKey: .addressUuidString)
        self.requestUuidString = try container.decodeIfPresent(String.self, forKey: .requestUuidString)
        self.memoMessage = try container.decodeIfPresent(String.self, forKey: .memoMessage)
        self.isUnread = try container.decode(Bool.self, forKey: .isUnread)
        self.interactionUniqueId = try container.decodeIfPresent(String.self, forKey: .interactionUniqueId)
        let mobileCoinData = try container.decodeIfPresent(Data.self, forKey: .mobileCoin)
        self.mobileCoin = try mobileCoinData.map { try LegacySDSSerializer().deserializeLegacySDSData($0, ofClass: MobileCoinPayment.self) }
        self.mcLedgerBlockIndex = try container.decode(UInt64.self, forKey: .mcLedgerBlockIndex)
        self.mcTransactionData = try container.decodeIfPresent(Data.self, forKey: .mcTransactionData)
        self.mcReceiptData = try container.decodeIfPresent(Data.self, forKey: .mcReceiptData)
    }

    public func encode(to encoder: any Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.id, forKey: .id)
        try container.encode(self.uniqueId, forKey: .uniqueId)
        try container.encode(Self.recordType.rawValue, forKey: .recordType)
        try container.encode(self.paymentType, forKey: .paymentType)
        try container.encode(self.paymentState, forKey: .paymentState)
        try container.encode(self.paymentFailure, forKey: .paymentFailure)
        try container.encode(self.paymentAmount.map { LegacySDSSerializer().serializeAsLegacySDSData($0) }, forKey: .paymentAmount)
        try container.encode(self.createdTimestamp, forKey: .createdTimestamp)
        try container.encode(self.addressUuidString, forKey: .addressUuidString)
        try container.encode(self.requestUuidString, forKey: .requestUuidString)
        try container.encode(self.memoMessage, forKey: .memoMessage)
        try container.encode(self.isUnread, forKey: .isUnread)
        try container.encode(self.interactionUniqueId, forKey: .interactionUniqueId)
        try container.encode(self.mobileCoin.map { LegacySDSSerializer().serializeAsLegacySDSData($0) }, forKey: .mobileCoin)
        try container.encode(self.mcLedgerBlockIndex, forKey: .mcLedgerBlockIndex)
        try container.encode(self.mcTransactionData, forKey: .mcTransactionData)
        try container.encode(self.mcReceiptData, forKey: .mcReceiptData)
    }

    public init(
        paymentType: TSPaymentType,
        paymentState: TSPaymentState,
        paymentAmount: TSPaymentAmount?,
        createdDate: Date,
        senderOrRecipientAci: Aci?,
        memoMessage: String?,
        isUnread: Bool,
        interactionUniqueId: String?,
        mobileCoin: MobileCoinPayment,
    ) {
        self.uniqueId = UUID().uuidString
        self.paymentType = paymentType
        self.paymentState = paymentState
        self.paymentFailure = .none
        self.paymentAmount = paymentAmount
        self.createdTimestamp = createdDate.ows_millisecondsSince1970
        self.addressUuidString = senderOrRecipientAci?.serviceIdUppercaseString
        self.memoMessage = memoMessage
        self.requestUuidString = nil
        self.isUnread = isUnread
        self.interactionUniqueId = interactionUniqueId
        self.mobileCoin = mobileCoin

        self.mcLedgerBlockIndex = mobileCoin.ledgerBlockIndex
        self.mcTransactionData = mobileCoin.transactionData
        self.mcReceiptData = mobileCoin.receiptData

        super.init()

        owsAssertDebug(self.isValid)
        Logger.info("Creating payment model: \(self.descriptionForLogs)")
    }

    override public var hash: Int {
        var hasher = Hasher()
        hasher.combine(self.addressUuidString)
        hasher.combine(self.createdTimestamp)
        hasher.combine(self.interactionUniqueId)
        hasher.combine(self.isUnread)
        hasher.combine(self.mcLedgerBlockIndex)
        hasher.combine(self.mcReceiptData)
        hasher.combine(self.mcTransactionData)
        hasher.combine(self.memoMessage)
        hasher.combine(self.mobileCoin)
        hasher.combine(self.paymentAmount)
        hasher.combine(self.paymentFailure)
        hasher.combine(self.paymentState)
        hasher.combine(self.paymentType)
        hasher.combine(self.requestUuidString)
        return hasher.finalize()
    }

    override public func isEqual(_ object: Any?) -> Bool {
        guard let object = object as? Self else { return false }
        guard self.addressUuidString == object.addressUuidString else { return false }
        guard self.createdTimestamp == object.createdTimestamp else { return false }
        guard self.interactionUniqueId == object.interactionUniqueId else { return false }
        guard self.isUnread == object.isUnread else { return false }
        guard self.mcLedgerBlockIndex == object.mcLedgerBlockIndex else { return false }
        guard self.mcReceiptData == object.mcReceiptData else { return false }
        guard self.mcTransactionData == object.mcTransactionData else { return false }
        guard self.memoMessage == object.memoMessage else { return false }
        guard self.mobileCoin == object.mobileCoin else { return false }
        guard self.paymentAmount == object.paymentAmount else { return false }
        guard self.paymentFailure == object.paymentFailure else { return false }
        guard self.paymentState == object.paymentState else { return false }
        guard self.paymentType == object.paymentType else { return false }
        guard self.requestUuidString == object.requestUuidString else { return false }
        return true
    }

    public var createdDate: Date {
        return Date(millisecondsSince1970: self.createdTimestamp)
    }

    public var senderOrRecipientAci: Aci? {
        return Aci.parseFrom(aciString: self.addressUuidString)
    }

    // This uses ledgerBlockDate if available and createdDate otherwise.
    public var sortDate: Date {
        return self.mcLedgerBlockDate ?? self.createdDate
    }

    public func update(paymentState: TSPaymentState, transaction: DBWriteTransaction) {
        anyUpdate(transaction: transaction) {
            owsAssertDebug($0.paymentState.isIncoming == paymentState.isIncoming)
            $0.paymentState = paymentState
        }
    }

    public func update(mcLedgerBlockIndex: UInt64, transaction: DBWriteTransaction) {
        owsAssertDebug(mcLedgerBlockIndex > 0)

        anyUpdate(transaction: transaction) {
            owsAssertDebug(!$0.hasMCLedgerBlockIndex)
            $0.mobileCoin = MobileCoinPayment.copy($0.mobileCoin, withLedgerBlockIndex: mcLedgerBlockIndex)
            $0.mcLedgerBlockIndex = mcLedgerBlockIndex
            owsAssertDebug($0.mobileCoin != nil)
        }
    }

    public func update(mcLedgerBlockTimestamp: UInt64, transaction: DBWriteTransaction) {
        owsAssertDebug(mcLedgerBlockTimestamp > 0)

        anyUpdate(transaction: transaction) {
            owsAssertDebug(!$0.hasMCLedgerBlockTimestamp)
            $0.mobileCoin = MobileCoinPayment.copy($0.mobileCoin, withLedgerBlockTimestamp: mcLedgerBlockTimestamp)
            owsAssertDebug($0.mobileCoin != nil)
        }
    }

    public func update(withPaymentFailure paymentFailure: TSPaymentFailure, paymentState: TSPaymentState, transaction: DBWriteTransaction) {
        owsAssertDebug(paymentFailure != .none)
        owsAssertDebug(paymentState == .incomingFailed || paymentState == .outgoingFailed)

        anyUpdate(transaction: transaction) {
            owsAssertDebug($0.paymentState.isIncoming == paymentState.isIncoming)

            $0.paymentState = paymentState
            $0.paymentFailure = paymentFailure

            // Scrub any MC state associated with the failure payment.
            $0.mobileCoin = nil
            $0.mcLedgerBlockIndex = 0
            $0.mcTransactionData = nil
            $0.mcReceiptData = nil
        }
    }

    public func update(withPaymentAmount paymentAmount: TSPaymentAmount, transaction: DBWriteTransaction) {
        anyUpdate(transaction: transaction) {
            owsAssertDebug($0.paymentAmount == nil || ($0.paymentAmount!.currency == paymentAmount.currency && $0.paymentAmount!.picoMob == paymentAmount.picoMob))
            $0.paymentAmount = paymentAmount
        }
    }

    public func update(withIsUnread isUnread: Bool, transaction: DBWriteTransaction) {
        anyUpdate(transaction: transaction) {
            $0.isUnread = isUnread
        }
    }

    public func update(withInteractionUniqueId interactionUniqueId: String, transaction: DBWriteTransaction) {
        anyUpdate(transaction: transaction) {
            $0.interactionUniqueId = interactionUniqueId
        }
    }

    public func anyWillInsert(transaction: DBWriteTransaction) {
        owsAssertDebug(self.isValid)
        SSKEnvironment.shared.paymentsEventsRef.willInsertPayment(self, transaction: transaction)
    }

    public func anyDidInsert(transaction: DBWriteTransaction) {
        owsAssertDebug(self.isValid)
    }

    public func anyWillUpdate(transaction: DBWriteTransaction) {
        owsAssertDebug(self.isValid)
        SSKEnvironment.shared.paymentsEventsRef.willUpdatePayment(self, transaction: transaction)
    }

    public func anyDidUpdate(transaction: DBWriteTransaction) {
        owsAssertDebug(self.isValid)
    }
}

// MARK: - StringInterpolation

public extension String.StringInterpolation {
    mutating func appendInterpolation(paymentModelColumn column: TSPaymentModel.CodingKeys) {
        appendLiteral(column.rawValue)
    }
}