diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 5531e8f5c..3ae91a344 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -199,6 +199,7 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { sheets: SheetViewModel, canRetryPreparation: Bool, handleSubscriptionNotification: () async -> Void, + allowanceProposal: () -> PaykitAllowanceEntry? = { nil }, presentPaymentRequest: () async -> Void ) async { guard sheets.activeSheetConfiguration == nil || canRetryPreparation, @@ -219,6 +220,10 @@ enum IncomingPaykitPaymentRequestPresentationDispatcher { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription))) return } + if let allowance = allowanceProposal() { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .allowanceReview(allowance))) + return + } await presentPaymentRequest() } @@ -359,6 +364,7 @@ struct AppScene: View { @State private var receivedPaymentBackfillCache = PaykitReceivedPaymentBackfillCache( activityChanges: CoreService.shared.activity.activitiesChangedPublisher ) + @State private var paykitAllowanceManager = PaykitAllowanceManager() @State private var hideSplash = false @State private var removeSplash = false @@ -594,6 +600,7 @@ struct AppScene: View { .environment(wallet.usdtWallet) .environment(calculatorInputManager) .environment(paykitPaymentRequestManager) + .environment(paykitAllowanceManager) } private var paykitEventContent: some View { @@ -606,6 +613,7 @@ struct AppScene: View { guard let session = pubkyProfile.automaticPaykitSession, PubkyPublicKeyFormat.matches(pk, session.publicKey) else { return } + await paykitAllowanceManager.activate(identity: pk) await handlePendingPaykitSubscriptionNotification() guard pubkyProfile.automaticPaykitSession == session else { return } try? await contactsManager.loadContacts(for: pk) @@ -624,6 +632,7 @@ struct AppScene: View { } else if authState == .idle { contactsManager.reset() paykitPaymentRequestManager.clear() + paykitAllowanceManager.deactivate() } } .onReceive(contactsManager.savedContactsChangedPublisher) { contacts in @@ -664,6 +673,9 @@ struct AppScene: View { guard !Task.isCancelled else { return } paykitProofRefresh.complete(request) } + .onReceive(PaykitAllowanceExecutor.eventPublisher.receive(on: DispatchQueue.main)) { event in + handlePaykitAllowanceEvent(event) + } .onReceive(PaykitPaymentProofService.onchainPaymentResolutionPublisher) { resolution in Task { @MainActor in app.retainPaykitOnchainPaymentResolution(resolution, identity: pubkyProfile.publicKey) @@ -701,6 +713,12 @@ struct AppScene: View { Task { await presentNextIncomingPaykitItem() } } } + .onChange(of: paykitAllowanceManager.isUpdating) { _, isUpdating in + // A request held back while allowances were updating is presented as soon as they settle. + if !isUpdating { + Task { await presentNextIncomingPaykitItem() } + } + } .onChange(of: incomingPaykitPaymentRequestPresentationState) { previous, current in handleIncomingPaykitPaymentRequestPresentationStateChange(from: previous, to: current) } @@ -1407,6 +1425,19 @@ struct AppScene: View { let refreshed = await paykitPaymentRequestManager.refresh( mode: mode, forceFresh: forceFresh, messagePriority: messagePriority, canRefresh: canRefresh ) + // Deleting the profile withdraws the payment lists an automatic payment would consume. + if canRefresh(), pubkyProfile.automaticPaykitSession == session, !pubkyProfile.isDeletingProfile { + await paykitAllowanceManager.refresh() + // A request whose manual sheet is already open stays with the user. + let manualRequestId = incomingPaymentRequestPreparation.flatMap { $0.ownsSheet(sheets) ? $0.request?.id : nil } + let handledAutomatically = await paykitAllowanceManager.processIncomingRequests( + paykitPaymentRequestManager.pendingRequests.filter { $0.id != manualRequestId } + ) + let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds() + if handledAutomatically || adoptedAcceptances, canRefresh() { + await paykitPaymentRequestManager.refresh(mode: .stored, canRefresh: canRefresh) + } + } guard pubkyProfile.authState == .authenticated, PubkyPublicKeyFormat.matches(identity, pubkyProfile.publicKey) else { return false } @@ -1566,6 +1597,20 @@ struct AppScene: View { else { return } for request in requests { guard paykitPaymentRequestManager.isCurrentPresentation(request) else { return } + if await paykitAllowanceManager.isAutomaticallyHandling(request) { + // The next refresh presents it if the allowance flow hands it back; presenting again now would spin on it. + if paykitPaymentRequestManager.requestedPresentationId == request.id { + app.toast(type: .info, title: t("subscriptions__allowance_payment_in_progress")) + paykitPaymentRequestManager.releaseRequestedPresentation(request) + } + shouldPresentNextRequest = false + continue + } + // A refresh or pass still running may hand this request to the allowance flow; a later round presents it. + if paykitAllowanceManager.isUpdating, paykitAllowanceManager.mayCover(request) { + shouldPresentNextRequest = false + continue + } let preparation: IncomingPaykitPaymentRequestPreparation if let current = incomingPaymentRequestPreparation, current.ownsSheet(sheets) { guard current.request?.id == request.id else { continue } @@ -1898,6 +1943,44 @@ struct AppScene: View { await presentNextIncomingPaykitPaymentRequest() } + private func handlePaykitAllowanceEvent(_ event: PaykitAllowanceEvent) { + switch event { + case let .paidAutomatically(counterparty, amountSats, paymentId): + let title = t("subscriptions__allowance_executed_title") + let description = t( + "subscriptions__allowance_executed_description", + variables: ["amount": AllowanceAmountText.fiat(sats: amountSats, currency: currency), "name": contactName(counterparty)] + ) + PaykitAllowanceNotifier.post(title: title, body: description, fallback: { + app.toast(type: .lightning, title: title, description: description, accessibilityIdentifier: "AllowancePaidToast") + }) + Task { + await paykitAllowanceManager.refresh() + do { + try await activity.setContact(counterparty, forPaymentId: paymentId) + } catch { + Logger.warn("Failed to set contact for an automatic allowance payment: \(error)", context: "AppScene") + } + } + case let .limitReached(counterparty, amountSats): + let title = t("subscriptions__allowance_limit_title") + let description = t( + "subscriptions__allowance_limit_description", + variables: ["amount": AllowanceAmountText.fiat(sats: amountSats, currency: currency), "name": contactName(counterparty)] + ) + PaykitAllowanceNotifier.post(title: title, body: description, fallback: { + app.toast(type: .warning, title: title, description: description, accessibilityIdentifier: "AllowanceLimitToast") + }) + case .ledgerChanged: + Task { await paykitAllowanceManager.refresh() } + } + } + + private func contactName(_ publicKey: String) -> String { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }?.displayName + ?? PubkyPublicKeyFormat.displayTruncated(publicKey) + } + private func presentNextIncomingPaykitItem() async { guard scenePhase == .active, pubkyProfile.automaticPaykitSession != nil, @@ -1908,6 +1991,7 @@ struct AppScene: View { sheets: sheets, canRetryPreparation: canRetryIncomingPaymentRequestPreparation, handleSubscriptionNotification: handlePendingPaykitSubscriptionNotification, + allowanceProposal: paykitAllowanceManager.proposalForPresentation, presentPaymentRequest: presentNextIncomingPaykitPaymentRequest ) } diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift index 22807679c..48763f950 100644 --- a/Bitkit/Models/PaykitPaymentStateBackup.swift +++ b/Bitkit/Models/PaykitPaymentStateBackup.swift @@ -77,6 +77,7 @@ struct PaykitPaymentStateBackup: Codable { let proofData: String? let conversionQuoteId: String? let billingPeriod: Period? + let allowanceId: String? let onchainAddress: String? let onchainAmountSats: UInt64? let onchainWalletId: String? @@ -101,6 +102,7 @@ struct PaykitPaymentStateBackup: Codable { proofData = proof.proofData conversionQuoteId = proof.conversionQuoteId billingPeriod = proof.billingPeriod.map { Period(startsAt: $0.sdkValue.startsAt, endsAt: $0.sdkValue.endsAt) } + allowanceId = proof.allowanceId onchainAddress = proof.onchainAddress onchainAmountSats = proof.onchainAmountSats onchainWalletId = proof.onchainWalletId @@ -129,6 +131,7 @@ struct PaykitPaymentStateBackup: Codable { paymentEndpointIdentifier: paymentEndpointIdentifier, kind: kind, billingPeriod: period, + allowanceId: allowanceId, paymentStarted: paymentStarted, paymentIdentifier: paymentIdentifier, proofData: proofData, diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index 0bcf440cd..3c090e3fa 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1580,3 +1580,50 @@ "wallet__receive_liquidity__label_additional" = "سيولة رصيد إنفاق إضافية"; "wallet__receive_cjit_error_max__title" = "الحد الأقصى لسعة الاستلام"; "wallet__receive_cjit_error_max__description" = "الحد الأقصى الذي يمكنك استلامه في رصيد الإنفاق الآن هو ₿ {amount}."; +"subscriptions__allowances" = "المخصصات"; +"subscriptions__allowances_empty_headline" = "أعدّ\nالمخصصات"; +"subscriptions__allowances_empty_description" = "يمكنك تعيين حدود إنفاق لتلبية طلبات الدفع من جهات الاتصال الموثوقة تلقائيًا."; +"subscriptions__allowance_add" = "إضافة مخصص"; +"subscriptions__allowance_title" = "المخصص"; +"subscriptions__allowance_choose_contact" = "اختر جهة اتصال"; +"subscriptions__allowance_no_contacts" = "أضف جهة اتصال أولًا. تغطي المخصصات طلبات الدفع من جهات اتصالك."; +"subscriptions__allowance_set_title" = "تعيين المخصص"; +"subscriptions__allowance_set_explanation" = "وافق تلقائيًا على طلبات الدفع من {name} ضمن هذه الحدود:"; +"subscriptions__allowance_payment_limit" = "حد الدفعة"; +"subscriptions__allowance_monthly_allowance" = "المخصص الشهري"; +"subscriptions__allowance_set_summary" = "ستُدفع الطلبات حتى {perPayment} تلقائيًا، بحد أقصى {monthly} شهريًا، دون سؤالك في كل مرة."; +"subscriptions__allowance_save" = "حفظ المخصص"; +"subscriptions__allowance_monthly_limit" = "الحد الشهري"; +"subscriptions__allowance_per_payment_short" = "{amount} للدفعة"; +"subscriptions__allowance_per_payment" = "لكل دفعة"; +"subscriptions__allowance_each_month" = "كل شهر"; +"subscriptions__allowance_up_to" = "حتى {amount}"; +"subscriptions__allowance_status_active" = "نشط"; +"subscriptions__allowance_status_waiting" = "بانتظار الرد"; +"subscriptions__allowance_status_needs_answer" = "بانتظار ردك"; +"subscriptions__allowance_status_scheduled" = "غير نشط بعد"; +"subscriptions__allowance_status_expired" = "منتهي الصلاحية"; +"subscriptions__allowance_status_declined" = "مرفوض"; +"subscriptions__allowance_status_conflicted" = "يحتاج إلى انتباه"; +"subscriptions__allowance_status_ended" = "منتهٍ"; +"subscriptions__allowance_paid_automatically" = "تم دفع {amount} تلقائيًا"; +"subscriptions__allowance_paid_so_far" = "مدفوع تلقائيًا"; +"subscriptions__allowance_offer_headline" = "{name} يعرض\nمخصصًا"; +"subscriptions__allowance_request_headline" = "{name} يطلب\nمخصصًا"; +"subscriptions__allowance_offer_explanation" = "ستدفع محفظة {name} طلباتك ضمن هذه الحدود دون السؤال في كل مرة. يمكن لأي منكما إنهاؤه."; +"subscriptions__allowance_request_explanation" = "ستدفع محفظتك طلبات {name} ضمن هذه الحدود دون سؤالك في كل مرة. يمكن لأي منكما إنهاؤه."; +"subscriptions__allowance_accept" = "قبول"; +"subscriptions__allowance_decline" = "رفض"; +"subscriptions__allowance_swipe_end" = "اسحب لإنهاء المخصص"; +"subscriptions__allowance_swipe_withdraw" = "اسحب للتراجع"; +"subscriptions__allowance_detail_active_allower" = "تُدفع الطلبات ضمن هذه الحدود تلقائيًا. أي طلب يتجاوزها يحتاج إلى موافقتك أولًا."; +"subscriptions__allowance_detail_active_allowee" = "تُدفع طلباتك ضمن هذه الحدود تلقائيًا."; +"subscriptions__allowance_detail_ended" = "انتهى هذا المخصص. سيتطلب كل طلب موافقتك من جديد."; +"subscriptions__allowance_error_not_linked" = "جهة الاتصال هذه غير متصلة بعد. حاول مرة أخرى بعد قليل."; +"subscriptions__allowance_error_unavailable" = "هذا المخصص غير متاح حاليًا."; +"subscriptions__allowance_payment_in_progress" = "يجري دفع هذا الطلب بالفعل."; +"subscriptions__allowance_executed_title" = "تم تنفيذ الدفع"; +"subscriptions__allowance_executed_description" = "أرسل الدفع التلقائي {amount} إلى {name}"; +"subscriptions__allowance_limit_title" = "تم بلوغ الحد"; +"subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع."; +"subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 640207c62..c0509d9f8 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1764,3 +1764,50 @@ "usdt__deposit_tron_refund_note" = "Les devolucions de TRON poden requerir l'assistència d'un operador d'Orchestra."; "usdt__deposit_refund_address_network" = "Adreça USDT a {network}"; "wallet__send_error_support" = "Contacta amb el suport"; +"subscriptions__allowances" = "Assignacions"; +"subscriptions__allowances_empty_headline" = "Configura\nassignacions"; +"subscriptions__allowances_empty_description" = "Pots establir límits de despesa per pagar automàticament les sol·licituds de pagament de contactes de confiança."; +"subscriptions__allowance_add" = "Afegir assignació"; +"subscriptions__allowance_title" = "Assignació"; +"subscriptions__allowance_choose_contact" = "Triar contacte"; +"subscriptions__allowance_no_contacts" = "Afegeix primer un contacte. Les assignacions cobreixen les sol·licituds de pagament dels teus contactes."; +"subscriptions__allowance_set_title" = "Establir assignació"; +"subscriptions__allowance_set_explanation" = "Aprova automàticament les sol·licituds de pagament de {name} per sota d'aquests límits:"; +"subscriptions__allowance_payment_limit" = "Límit per pagament"; +"subscriptions__allowance_monthly_allowance" = "Assignació mensual"; +"subscriptions__allowance_set_summary" = "Les sol·licituds de fins a {perPayment} es pagaran automàticament, fins a {monthly} al mes, sense preguntar-te cada vegada."; +"subscriptions__allowance_save" = "Desar assignació"; +"subscriptions__allowance_monthly_limit" = "Límit mensual"; +"subscriptions__allowance_per_payment_short" = "{amount} per pagament"; +"subscriptions__allowance_per_payment" = "Per pagament"; +"subscriptions__allowance_each_month" = "Cada mes"; +"subscriptions__allowance_up_to" = "Fins a {amount}"; +"subscriptions__allowance_status_active" = "Activa"; +"subscriptions__allowance_status_waiting" = "Esperant resposta"; +"subscriptions__allowance_status_needs_answer" = "Esperant la teva resposta"; +"subscriptions__allowance_status_scheduled" = "Encara no activa"; +"subscriptions__allowance_status_expired" = "Caducada"; +"subscriptions__allowance_status_declined" = "Rebutjada"; +"subscriptions__allowance_status_conflicted" = "Requereix atenció"; +"subscriptions__allowance_status_ended" = "Finalitzada"; +"subscriptions__allowance_paid_automatically" = "{amount} pagat automàticament"; +"subscriptions__allowance_paid_so_far" = "Pagat automàticament"; +"subscriptions__allowance_offer_headline" = "{name} t'ofereix\nuna assignació"; +"subscriptions__allowance_request_headline" = "{name} demana\nuna assignació"; +"subscriptions__allowance_offer_explanation" = "La cartera de {name} pagarà les teves sol·licituds dins d'aquests límits sense preguntar cada vegada. Qualsevol de vosaltres hi pot posar fi."; +"subscriptions__allowance_request_explanation" = "La teva cartera pagarà les sol·licituds de {name} dins d'aquests límits sense preguntar-te cada vegada. Qualsevol de vosaltres hi pot posar fi."; +"subscriptions__allowance_accept" = "Acceptar"; +"subscriptions__allowance_decline" = "Rebutjar"; +"subscriptions__allowance_swipe_end" = "Desplaça per finalitzar l'assignació"; +"subscriptions__allowance_swipe_withdraw" = "Desplaça per retirar"; +"subscriptions__allowance_detail_active_allower" = "Les sol·licituds dins d'aquests límits es paguen automàticament. Qualsevol import superior et demanarà confirmació abans."; +"subscriptions__allowance_detail_active_allowee" = "Les teves sol·licituds dins d'aquests límits es paguen automàticament."; +"subscriptions__allowance_detail_ended" = "Aquesta assignació ha finalitzat. Cada sol·licitud tornarà a demanar confirmació."; +"subscriptions__allowance_error_not_linked" = "Aquest contacte encara no està connectat. Torna-ho a provar d'aquí a un moment."; +"subscriptions__allowance_error_unavailable" = "Aquesta assignació no està disponible ara mateix."; +"subscriptions__allowance_payment_in_progress" = "Aquesta sol·licitud ja s'està pagant."; +"subscriptions__allowance_executed_title" = "Pagament executat"; +"subscriptions__allowance_executed_description" = "El pagament automàtic ha enviat {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Límit assolit"; +"subscriptions__allowance_limit_description" = "Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar."; +"subscriptions__allowance_auto_paid" = "Pagat automàticament"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index b28cb1392..6dd33eeb9 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1903,3 +1903,50 @@ "usdt__deposit_refund_confirmation" = "Požádat o vrácení vkladu {amount} USDT?\n\nSíť:\n{network}\n\nAdresa pro vrácení:\n{address}\n\nPotvrďte, že tato adresa může přijímat USDT v síti {shortNetwork}. Mohou být účtovány poplatky za vrácení."; "usdt__deposit_tron_refund_note" = "Vrácení prostředků v síti TRON může vyžadovat pomoc operátora Orchestra."; "usdt__deposit_refund_address_network" = "Adresa USDT v síti {network}"; +"subscriptions__allowances" = "Povolení plateb"; +"subscriptions__allowances_empty_headline" = "Nastavte\npovolení plateb"; +"subscriptions__allowances_empty_description" = "Můžete nastavit limity útraty a automaticky tak vyřizovat žádosti o platbu od důvěryhodných kontaktů."; +"subscriptions__allowance_add" = "Přidat povolení"; +"subscriptions__allowance_title" = "Povolení plateb"; +"subscriptions__allowance_choose_contact" = "Vybrat kontakt"; +"subscriptions__allowance_no_contacts" = "Nejprve přidejte kontakt. Povolení plateb se vztahují na žádosti o platbu od vašich kontaktů."; +"subscriptions__allowance_set_title" = "Nastavit povolení"; +"subscriptions__allowance_set_explanation" = "Automaticky schvalovat žádosti o platbu od {name} do těchto limitů:"; +"subscriptions__allowance_payment_limit" = "Limit platby"; +"subscriptions__allowance_monthly_allowance" = "Měsíční povolená částka"; +"subscriptions__allowance_set_summary" = "Žádosti do {perPayment} budou placeny automaticky, maximálně {monthly} měsíčně, aniž bychom se vás pokaždé ptali."; +"subscriptions__allowance_save" = "Uložit povolení"; +"subscriptions__allowance_monthly_limit" = "Měsíční limit"; +"subscriptions__allowance_per_payment_short" = "{amount} za platbu"; +"subscriptions__allowance_per_payment" = "Za platbu"; +"subscriptions__allowance_each_month" = "Každý měsíc"; +"subscriptions__allowance_up_to" = "Až {amount}"; +"subscriptions__allowance_status_active" = "Aktivní"; +"subscriptions__allowance_status_waiting" = "Čeká na odpověď"; +"subscriptions__allowance_status_needs_answer" = "Čeká na vaši odpověď"; +"subscriptions__allowance_status_scheduled" = "Zatím neaktivní"; +"subscriptions__allowance_status_expired" = "Vypršelo"; +"subscriptions__allowance_status_declined" = "Odmítnuto"; +"subscriptions__allowance_status_conflicted" = "Vyžaduje pozornost"; +"subscriptions__allowance_status_ended" = "Ukončeno"; +"subscriptions__allowance_paid_automatically" = "{amount} zaplaceno automaticky"; +"subscriptions__allowance_paid_so_far" = "Zaplaceno automaticky"; +"subscriptions__allowance_offer_headline" = "{name} nabízí\npovolení plateb"; +"subscriptions__allowance_request_headline" = "{name} žádá o\npovolení plateb"; +"subscriptions__allowance_offer_explanation" = "Peněženka {name} bude platit vaše žádosti do těchto limitů, aniž by se pokaždé ptala. Kdokoli z vás ho může ukončit."; +"subscriptions__allowance_request_explanation" = "Vaše peněženka bude platit žádosti {name} do těchto limitů, aniž by se vás pokaždé ptala. Kdokoli z vás ho může ukončit."; +"subscriptions__allowance_accept" = "Přijmout"; +"subscriptions__allowance_decline" = "Odmítnout"; +"subscriptions__allowance_swipe_end" = "Přejeďte pro ukončení povolení"; +"subscriptions__allowance_swipe_withdraw" = "Přejeďte pro stažení"; +"subscriptions__allowance_detail_active_allower" = "Žádosti do těchto limitů se platí automaticky. U čehokoli nad nimi se vás nejprve zeptáme."; +"subscriptions__allowance_detail_active_allowee" = "Vaše žádosti do těchto limitů se platí automaticky."; +"subscriptions__allowance_detail_ended" = "Toto povolení skončilo. Každá žádost bude opět vyžadovat potvrzení."; +"subscriptions__allowance_error_not_linked" = "Tento kontakt zatím není propojen. Zkuste to znovu za chvíli."; +"subscriptions__allowance_error_unavailable" = "Toto povolení není momentálně k dispozici."; +"subscriptions__allowance_payment_in_progress" = "Tato žádost se již platí."; +"subscriptions__allowance_executed_title" = "Platba provedena"; +"subscriptions__allowance_executed_description" = "Automatická platba odeslala {amount} uživateli {name}"; +"subscriptions__allowance_limit_title" = "Limit dosažen"; +"subscriptions__allowance_limit_description" = "Žádost o {amount} od {name} přesahuje vaše povolení. Zkontrolujte ji a zaplaťte."; +"subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index e082793b2..ac1f3bf3a 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1900,3 +1900,50 @@ "usdt__deposit_refund_confirmation" = "Rückerstattung für die Einzahlung von {amount} USDT anfordern?\n\nNetzwerk:\n{network}\n\nRückerstattungsadresse:\n{address}\n\nStelle sicher, dass diese Adresse USDT auf {shortNetwork} empfangen kann. Es können Rückerstattungsgebühren anfallen."; "usdt__deposit_tron_refund_note" = "TRON-Rückerstattungen können die Unterstützung eines Orchestra-Betreibers erfordern."; "usdt__deposit_refund_address_network" = "USDT-Adresse auf {network}"; +"subscriptions__allowances" = "Kontingente"; +"subscriptions__allowances_empty_headline" = "Kontingente\neinrichten"; +"subscriptions__allowances_empty_description" = "Lege Ausgabenlimits fest, um Zahlungsanforderungen vertrauenswürdiger Kontakte automatisch zu bezahlen."; +"subscriptions__allowance_add" = "Kontingent hinzufügen"; +"subscriptions__allowance_title" = "Kontingent"; +"subscriptions__allowance_choose_contact" = "Kontakt auswählen"; +"subscriptions__allowance_no_contacts" = "Füge zuerst einen Kontakt hinzu. Kontingente gelten für Zahlungsanforderungen deiner Kontakte."; +"subscriptions__allowance_set_title" = "Kontingent festlegen"; +"subscriptions__allowance_set_explanation" = "Zahlungsanforderungen von {name} unterhalb dieser Limits automatisch genehmigen:"; +"subscriptions__allowance_payment_limit" = "Zahlungslimit"; +"subscriptions__allowance_monthly_allowance" = "Monatliches Kontingent"; +"subscriptions__allowance_set_summary" = "Anforderungen bis {perPayment} werden automatisch bezahlt, bis zu {monthly} im Monat, ohne dich jedes Mal zu fragen."; +"subscriptions__allowance_save" = "Kontingent speichern"; +"subscriptions__allowance_monthly_limit" = "Monatliches Limit"; +"subscriptions__allowance_per_payment_short" = "{amount} pro Zahlung"; +"subscriptions__allowance_per_payment" = "Pro Zahlung"; +"subscriptions__allowance_each_month" = "Jeden Monat"; +"subscriptions__allowance_up_to" = "Bis zu {amount}"; +"subscriptions__allowance_status_active" = "Aktiv"; +"subscriptions__allowance_status_waiting" = "Wartet auf Antwort"; +"subscriptions__allowance_status_needs_answer" = "Wartet auf deine Antwort"; +"subscriptions__allowance_status_scheduled" = "Noch nicht aktiv"; +"subscriptions__allowance_status_expired" = "Abgelaufen"; +"subscriptions__allowance_status_declined" = "Abgelehnt"; +"subscriptions__allowance_status_conflicted" = "Prüfung nötig"; +"subscriptions__allowance_status_ended" = "Beendet"; +"subscriptions__allowance_paid_automatically" = "{amount} automatisch bezahlt"; +"subscriptions__allowance_paid_so_far" = "Automatisch bezahlt"; +"subscriptions__allowance_offer_headline" = "{name} bietet dir\nein Kontingent"; +"subscriptions__allowance_request_headline" = "{name} bittet um\nein Kontingent"; +"subscriptions__allowance_offer_explanation" = "Das Wallet von {name} bezahlt deine Anforderungen innerhalb dieser Limits, ohne jedes Mal nachzufragen. Jeder von euch kann es beenden."; +"subscriptions__allowance_request_explanation" = "Dein Wallet bezahlt die Anforderungen von {name} innerhalb dieser Limits, ohne dich jedes Mal zu fragen. Jeder von euch kann es beenden."; +"subscriptions__allowance_accept" = "Annehmen"; +"subscriptions__allowance_decline" = "Ablehnen"; +"subscriptions__allowance_swipe_end" = "Wischen zum Beenden"; +"subscriptions__allowance_swipe_withdraw" = "Wischen zum Zurückziehen"; +"subscriptions__allowance_detail_active_allower" = "Anforderungen innerhalb dieser Limits werden automatisch bezahlt. Alles darüber musst du zuerst bestätigen."; +"subscriptions__allowance_detail_active_allowee" = "Deine Anforderungen innerhalb dieser Limits werden automatisch bezahlt."; +"subscriptions__allowance_detail_ended" = "Dieses Kontingent ist beendet. Jede Anforderung muss wieder bestätigt werden."; +"subscriptions__allowance_error_not_linked" = "Dieser Kontakt ist noch nicht verbunden. Versuche es gleich noch einmal."; +"subscriptions__allowance_error_unavailable" = "Dieses Kontingent ist gerade nicht verfügbar."; +"subscriptions__allowance_payment_in_progress" = "Diese Anforderung wird bereits bezahlt."; +"subscriptions__allowance_executed_title" = "Zahlung ausgeführt"; +"subscriptions__allowance_executed_description" = "Auto-Pay hat {amount} an {name} gesendet"; +"subscriptions__allowance_limit_title" = "Limit erreicht"; +"subscriptions__allowance_limit_description" = "Eine Anforderung über {amount} von {name} übersteigt dein Kontingent. Prüfe sie, um zu bezahlen."; +"subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index a417f60da..6ac33f347 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1635,3 +1635,50 @@ "usdt__deposit_tron_refund_note" = "Οι επιστροφές στο TRON μπορεί να απαιτούν βοήθεια από χειριστή του Orchestra."; "usdt__deposit_refund_address_network" = "Διεύθυνση USDT στο {network}"; "wallet__send_error_support" = "Επικοινωνία με Υποστήριξη"; +"subscriptions__allowances" = "Προεγκρίσεις"; +"subscriptions__allowances_empty_headline" = "Ρύθμισε\nπροεγκρίσεις"; +"subscriptions__allowances_empty_description" = "Μπορείς να ορίσεις όρια δαπανών για να εξοφλούνται αυτόματα αιτήματα πληρωμής από αξιόπιστες επαφές."; +"subscriptions__allowance_add" = "Προσθήκη προέγκρισης"; +"subscriptions__allowance_title" = "Προέγκριση"; +"subscriptions__allowance_choose_contact" = "Επιλογή επαφής"; +"subscriptions__allowance_no_contacts" = "Πρόσθεσε πρώτα μια επαφή. Οι προεγκρίσεις καλύπτουν αιτήματα πληρωμής από τις επαφές σου."; +"subscriptions__allowance_set_title" = "Ορισμός προέγκρισης"; +"subscriptions__allowance_set_explanation" = "Αυτόματη έγκριση αιτημάτων πληρωμής από {name} κάτω από αυτά τα όρια:"; +"subscriptions__allowance_payment_limit" = "Όριο πληρωμής"; +"subscriptions__allowance_monthly_allowance" = "Μηνιαίο ποσό"; +"subscriptions__allowance_set_summary" = "Τα αιτήματα έως {perPayment} θα πληρώνονται αυτόματα, έως {monthly} τον μήνα, χωρίς να σε ρωτάμε κάθε φορά."; +"subscriptions__allowance_save" = "Αποθήκευση προέγκρισης"; +"subscriptions__allowance_monthly_limit" = "Μηνιαίο όριο"; +"subscriptions__allowance_per_payment_short" = "{amount} ανά πληρωμή"; +"subscriptions__allowance_per_payment" = "Ανά πληρωμή"; +"subscriptions__allowance_each_month" = "Κάθε μήνα"; +"subscriptions__allowance_up_to" = "Έως {amount}"; +"subscriptions__allowance_status_active" = "Ενεργή"; +"subscriptions__allowance_status_waiting" = "Αναμονή απάντησης"; +"subscriptions__allowance_status_needs_answer" = "Αναμένεται η απάντησή σου"; +"subscriptions__allowance_status_scheduled" = "Δεν είναι ακόμη ενεργή"; +"subscriptions__allowance_status_expired" = "Έληξε"; +"subscriptions__allowance_status_declined" = "Απορρίφθηκε"; +"subscriptions__allowance_status_conflicted" = "Χρειάζεται προσοχή"; +"subscriptions__allowance_status_ended" = "Τερματίστηκε"; +"subscriptions__allowance_paid_automatically" = "{amount} πληρώθηκαν αυτόματα"; +"subscriptions__allowance_paid_so_far" = "Πληρώθηκαν αυτόματα"; +"subscriptions__allowance_offer_headline" = "{name} προσφέρει\nμια προέγκριση"; +"subscriptions__allowance_request_headline" = "{name} ζητά\nμια προέγκριση"; +"subscriptions__allowance_offer_explanation" = "Το πορτοφόλι του/της {name} θα πληρώνει τα αιτήματά σου εντός αυτών των ορίων χωρίς να ρωτά κάθε φορά. Οποιοσδήποτε από εσάς μπορεί να την τερματίσει."; +"subscriptions__allowance_request_explanation" = "Το πορτοφόλι σου θα πληρώνει τα αιτήματα του/της {name} εντός αυτών των ορίων χωρίς να σε ρωτά κάθε φορά. Οποιοσδήποτε από εσάς μπορεί να την τερματίσει."; +"subscriptions__allowance_accept" = "Αποδοχή"; +"subscriptions__allowance_decline" = "Απόρριψη"; +"subscriptions__allowance_swipe_end" = "Σύρε για τερματισμό προέγκρισης"; +"subscriptions__allowance_swipe_withdraw" = "Σύρε για απόσυρση"; +"subscriptions__allowance_detail_active_allower" = "Τα αιτήματα εντός αυτών των ορίων πληρώνονται αυτόματα. Για οτιδήποτε τα υπερβαίνει, θα ερωτηθείς πρώτα."; +"subscriptions__allowance_detail_active_allowee" = "Τα αιτήματά σου εντός αυτών των ορίων πληρώνονται αυτόματα."; +"subscriptions__allowance_detail_ended" = "Αυτή η προέγκριση έχει τερματιστεί. Κάθε αίτημα θα χρειάζεται ξανά έγκριση."; +"subscriptions__allowance_error_not_linked" = "Αυτή η επαφή δεν έχει συνδεθεί ακόμη. Δοκίμασε ξανά σε λίγο."; +"subscriptions__allowance_error_unavailable" = "Αυτή η προέγκριση δεν είναι διαθέσιμη αυτή τη στιγμή."; +"subscriptions__allowance_payment_in_progress" = "Αυτό το αίτημα πληρώνεται ήδη."; +"subscriptions__allowance_executed_title" = "Η πληρωμή εκτελέστηκε"; +"subscriptions__allowance_executed_description" = "Η αυτόματη πληρωμή έστειλε {amount} σε {name}"; +"subscriptions__allowance_limit_title" = "Το όριο εξαντλήθηκε"; +"subscriptions__allowance_limit_description" = "Ένα αίτημα {amount} από {name} υπερβαίνει την προέγκρισή σου. Έλεγξέ το για να πληρώσεις."; +"subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 07b8118fb..3ecc58aae 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1775,6 +1775,53 @@ "settings__adv__pp_both" = "Both"; "settings__adv__pp_lightning_short" = "Lightning"; "settings__adv__pp_onchain_short" = "On-chain"; +"subscriptions__allowances" = "Allowances"; +"subscriptions__allowances_empty_headline" = "Set up\nallowances"; +"subscriptions__allowances_empty_description" = "You can set spending limits to automatically fulfill payment requests from trusted peers."; +"subscriptions__allowance_add" = "Add Allowance"; +"subscriptions__allowance_title" = "Allowance"; +"subscriptions__allowance_choose_contact" = "Choose Contact"; +"subscriptions__allowance_no_contacts" = "Add a contact first. Allowances cover payment requests from your contacts."; +"subscriptions__allowance_set_title" = "Set Allowance"; +"subscriptions__allowance_set_explanation" = "Automatically approve payment requests from {name} below these limits:"; +"subscriptions__allowance_payment_limit" = "Payment limit"; +"subscriptions__allowance_monthly_allowance" = "Monthly allowance"; +"subscriptions__allowance_set_summary" = "Requests up to {perPayment} will be paid automatically, up to {monthly} a month, without asking you each time."; +"subscriptions__allowance_save" = "Save Allowance"; +"subscriptions__allowance_monthly_limit" = "Monthly limit"; +"subscriptions__allowance_per_payment_short" = "{amount} a payment"; +"subscriptions__allowance_per_payment" = "Per payment"; +"subscriptions__allowance_each_month" = "Each month"; +"subscriptions__allowance_up_to" = "Up to {amount}"; +"subscriptions__allowance_status_active" = "Active"; +"subscriptions__allowance_status_waiting" = "Waiting for an answer"; +"subscriptions__allowance_status_needs_answer" = "Waiting for your answer"; +"subscriptions__allowance_status_scheduled" = "Not active yet"; +"subscriptions__allowance_status_expired" = "Expired"; +"subscriptions__allowance_status_declined" = "Declined"; +"subscriptions__allowance_status_conflicted" = "Needs attention"; +"subscriptions__allowance_status_ended" = "Ended"; +"subscriptions__allowance_paid_automatically" = "{amount} paid automatically"; +"subscriptions__allowance_paid_so_far" = "Paid automatically"; +"subscriptions__allowance_offer_headline" = "{name} offers\nan allowance"; +"subscriptions__allowance_request_headline" = "{name} asks for\nan allowance"; +"subscriptions__allowance_offer_explanation" = "{name}'s wallet will pay your requests within these limits without asking each time. Either of you can end it."; +"subscriptions__allowance_request_explanation" = "Your wallet will pay {name}'s requests within these limits without asking you each time. Either of you can end it."; +"subscriptions__allowance_accept" = "Accept"; +"subscriptions__allowance_decline" = "Decline"; +"subscriptions__allowance_swipe_end" = "Swipe To End Allowance"; +"subscriptions__allowance_swipe_withdraw" = "Swipe To Withdraw"; +"subscriptions__allowance_detail_active_allower" = "Requests within these limits are paid automatically. Anything above them asks you first."; +"subscriptions__allowance_detail_active_allowee" = "Your requests within these limits are paid automatically."; +"subscriptions__allowance_detail_ended" = "This allowance has ended. Every request asks again."; +"subscriptions__allowance_error_not_linked" = "This contact is not connected yet. Try again in a moment."; +"subscriptions__allowance_error_unavailable" = "This allowance is not available right now."; +"subscriptions__allowance_payment_in_progress" = "This request is already being paid."; +"subscriptions__allowance_executed_title" = "Payment Executed"; +"subscriptions__allowance_executed_description" = "Auto-pay sent {amount} to {name}"; +"subscriptions__allowance_limit_title" = "Limit Reached"; +"subscriptions__allowance_limit_description" = "A {amount} request from {name} is above your allowance. Review it to pay."; +"subscriptions__allowance_auto_paid" = "Auto-paid"; // On-chain acceptance and original-payment follow-up "wallet__onchain_send_unresolved" = "An earlier on-chain send is unresolved. Check its transaction before trying another send."; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index ba9bf100c..c49ada846 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1906,3 +1906,50 @@ "usdt__deposit_refund_confirmation" = "¿Solicitar el reembolso del depósito de {amount} USDT?\n\nRed:\n{network}\n\nDirección de reembolso:\n{address}\n\nConfirme que esta dirección puede recibir USDT en {shortNetwork}. Es posible que se apliquen comisiones de reembolso."; "usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra."; "usdt__deposit_refund_address_network" = "Dirección USDT en {network}"; +"subscriptions__allowances" = "Asignaciones"; +"subscriptions__allowances_empty_headline" = "Configure\nasignaciones"; +"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza."; +"subscriptions__allowance_add" = "Añadir asignación"; +"subscriptions__allowance_title" = "Asignación"; +"subscriptions__allowance_choose_contact" = "Elegir contacto"; +"subscriptions__allowance_no_contacts" = "Primero añada un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos."; +"subscriptions__allowance_set_title" = "Establecer asignación"; +"subscriptions__allowance_set_explanation" = "Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites:"; +"subscriptions__allowance_payment_limit" = "Límite por pago"; +"subscriptions__allowance_monthly_allowance" = "Asignación mensual"; +"subscriptions__allowance_set_summary" = "Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} por mes, sin preguntarle cada vez."; +"subscriptions__allowance_save" = "Guardar asignación"; +"subscriptions__allowance_monthly_limit" = "Límite mensual"; +"subscriptions__allowance_per_payment_short" = "{amount} por pago"; +"subscriptions__allowance_per_payment" = "Por pago"; +"subscriptions__allowance_each_month" = "Cada mes"; +"subscriptions__allowance_up_to" = "Hasta {amount}"; +"subscriptions__allowance_status_active" = "Activa"; +"subscriptions__allowance_status_waiting" = "Esperando respuesta"; +"subscriptions__allowance_status_needs_answer" = "Esperando su respuesta"; +"subscriptions__allowance_status_scheduled" = "Aún no activa"; +"subscriptions__allowance_status_expired" = "Vencida"; +"subscriptions__allowance_status_declined" = "Rechazada"; +"subscriptions__allowance_status_conflicted" = "Requiere atención"; +"subscriptions__allowance_status_ended" = "Finalizada"; +"subscriptions__allowance_paid_automatically" = "{amount} pagado automáticamente"; +"subscriptions__allowance_paid_so_far" = "Pagado automáticamente"; +"subscriptions__allowance_offer_headline" = "{name} le ofrece\nuna asignación"; +"subscriptions__allowance_request_headline" = "{name} solicita\nuna asignación"; +"subscriptions__allowance_offer_explanation" = "La billetera de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_request_explanation" = "Su billetera pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_accept" = "Aceptar"; +"subscriptions__allowance_decline" = "Rechazar"; +"subscriptions__allowance_swipe_end" = "Deslice para finalizar la asignación"; +"subscriptions__allowance_swipe_withdraw" = "Deslice para retirar"; +"subscriptions__allowance_detail_active_allower" = "Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier monto superior se le preguntará antes."; +"subscriptions__allowance_detail_active_allowee" = "Sus solicitudes dentro de estos límites se pagan automáticamente."; +"subscriptions__allowance_detail_ended" = "Esta asignación finalizó. Cada solicitud volverá a requerir su confirmación."; +"subscriptions__allowance_error_not_linked" = "Este contacto aún no está conectado. Inténtelo de nuevo en un momento."; +"subscriptions__allowance_error_unavailable" = "Esta asignación no está disponible en este momento."; +"subscriptions__allowance_payment_in_progress" = "Esta solicitud ya se está pagando."; +"subscriptions__allowance_executed_title" = "Pago ejecutado"; +"subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Límite alcanzado"; +"subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; +"subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index d9c7913bd..40cbbb900 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1784,3 +1784,50 @@ "usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra."; "usdt__deposit_refund_address_network" = "Dirección USDT en {network}"; "wallet__send_error_support" = "Contactar con el servicio de asistencia"; +"subscriptions__allowances" = "Asignaciones"; +"subscriptions__allowances_empty_headline" = "Configure\nasignaciones"; +"subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza."; +"subscriptions__allowance_add" = "Añadir asignación"; +"subscriptions__allowance_title" = "Asignación"; +"subscriptions__allowance_choose_contact" = "Elegir contacto"; +"subscriptions__allowance_no_contacts" = "Añada primero un contacto. Las asignaciones cubren las solicitudes de pago de sus contactos."; +"subscriptions__allowance_set_title" = "Establecer asignación"; +"subscriptions__allowance_set_explanation" = "Aprobar automáticamente las solicitudes de pago de {name} por debajo de estos límites:"; +"subscriptions__allowance_payment_limit" = "Límite por pago"; +"subscriptions__allowance_monthly_allowance" = "Asignación mensual"; +"subscriptions__allowance_set_summary" = "Las solicitudes de hasta {perPayment} se pagarán automáticamente, hasta {monthly} al mes, sin preguntarle cada vez."; +"subscriptions__allowance_save" = "Guardar asignación"; +"subscriptions__allowance_monthly_limit" = "Límite mensual"; +"subscriptions__allowance_per_payment_short" = "{amount} por pago"; +"subscriptions__allowance_per_payment" = "Por pago"; +"subscriptions__allowance_each_month" = "Cada mes"; +"subscriptions__allowance_up_to" = "Hasta {amount}"; +"subscriptions__allowance_status_active" = "Activa"; +"subscriptions__allowance_status_waiting" = "Esperando respuesta"; +"subscriptions__allowance_status_needs_answer" = "Esperando su respuesta"; +"subscriptions__allowance_status_scheduled" = "Aún no activa"; +"subscriptions__allowance_status_expired" = "Caducada"; +"subscriptions__allowance_status_declined" = "Rechazada"; +"subscriptions__allowance_status_conflicted" = "Requiere atención"; +"subscriptions__allowance_status_ended" = "Finalizada"; +"subscriptions__allowance_paid_automatically" = "{amount} pagado automáticamente"; +"subscriptions__allowance_paid_so_far" = "Pagado automáticamente"; +"subscriptions__allowance_offer_headline" = "{name} le ofrece\nuna asignación"; +"subscriptions__allowance_request_headline" = "{name} solicita\nuna asignación"; +"subscriptions__allowance_offer_explanation" = "El monedero de {name} pagará sus solicitudes dentro de estos límites sin preguntar cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_request_explanation" = "Su monedero pagará las solicitudes de {name} dentro de estos límites sin preguntarle cada vez. Cualquiera de los dos puede finalizarla."; +"subscriptions__allowance_accept" = "Aceptar"; +"subscriptions__allowance_decline" = "Rechazar"; +"subscriptions__allowance_swipe_end" = "Deslizar para finalizar la asignación"; +"subscriptions__allowance_swipe_withdraw" = "Deslizar para retirar"; +"subscriptions__allowance_detail_active_allower" = "Las solicitudes dentro de estos límites se pagan automáticamente. Para cualquier importe superior se le preguntará antes."; +"subscriptions__allowance_detail_active_allowee" = "Sus solicitudes dentro de estos límites se pagan automáticamente."; +"subscriptions__allowance_detail_ended" = "Esta asignación ha finalizado. Cada solicitud volverá a requerir su confirmación."; +"subscriptions__allowance_error_not_linked" = "Este contacto aún no está conectado. Inténtelo de nuevo en un momento."; +"subscriptions__allowance_error_unavailable" = "Esta asignación no está disponible en este momento."; +"subscriptions__allowance_payment_in_progress" = "Esta solicitud ya se está pagando."; +"subscriptions__allowance_executed_title" = "Pago ejecutado"; +"subscriptions__allowance_executed_description" = "El pago automático envió {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Límite alcanzado"; +"subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; +"subscriptions__allowance_auto_paid" = "Pagado automáticamente"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 52e840f68..00df90320 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1914,3 +1914,50 @@ "usdt__deposit_refund_confirmation" = "Demander le remboursement du dépôt de {amount} USDT ?\n\nRéseau :\n{network}\n\nAdresse de remboursement :\n{address}\n\nVérifiez que cette adresse peut recevoir des USDT sur {shortNetwork}. Des frais de remboursement peuvent s'appliquer."; "usdt__deposit_tron_refund_note" = "Les remboursements TRON peuvent nécessiter l'intervention d'un opérateur Orchestra."; "usdt__deposit_refund_address_network" = "Adresse USDT sur {network}"; +"subscriptions__allowances" = "Allocations"; +"subscriptions__allowances_empty_headline" = "Configurez\ndes allocations"; +"subscriptions__allowances_empty_description" = "Vous pouvez définir des limites de dépenses pour régler automatiquement les demandes de paiement de contacts de confiance."; +"subscriptions__allowance_add" = "Ajouter une allocation"; +"subscriptions__allowance_title" = "Allocation"; +"subscriptions__allowance_choose_contact" = "Choisir un contact"; +"subscriptions__allowance_no_contacts" = "Ajoutez d'abord un contact. Les allocations couvrent les demandes de paiement de vos contacts."; +"subscriptions__allowance_set_title" = "Définir l'allocation"; +"subscriptions__allowance_set_explanation" = "Approuver automatiquement les demandes de paiement de {name} sous ces limites :"; +"subscriptions__allowance_payment_limit" = "Limite par paiement"; +"subscriptions__allowance_monthly_allowance" = "Allocation mensuelle"; +"subscriptions__allowance_set_summary" = "Les demandes jusqu'à {perPayment} seront payées automatiquement, jusqu'à {monthly} par mois, sans vous demander à chaque fois."; +"subscriptions__allowance_save" = "Enregistrer l'allocation"; +"subscriptions__allowance_monthly_limit" = "Limite mensuelle"; +"subscriptions__allowance_per_payment_short" = "{amount} par paiement"; +"subscriptions__allowance_per_payment" = "Par paiement"; +"subscriptions__allowance_each_month" = "Chaque mois"; +"subscriptions__allowance_up_to" = "Jusqu'à {amount}"; +"subscriptions__allowance_status_active" = "Active"; +"subscriptions__allowance_status_waiting" = "En attente de réponse"; +"subscriptions__allowance_status_needs_answer" = "En attente de votre réponse"; +"subscriptions__allowance_status_scheduled" = "Pas encore active"; +"subscriptions__allowance_status_expired" = "Expirée"; +"subscriptions__allowance_status_declined" = "Refusée"; +"subscriptions__allowance_status_conflicted" = "Action requise"; +"subscriptions__allowance_status_ended" = "Terminée"; +"subscriptions__allowance_paid_automatically" = "{amount} payé automatiquement"; +"subscriptions__allowance_paid_so_far" = "Payé automatiquement"; +"subscriptions__allowance_offer_headline" = "{name} vous propose\nune allocation"; +"subscriptions__allowance_request_headline" = "{name} demande\nune allocation"; +"subscriptions__allowance_offer_explanation" = "Le portefeuille de {name} paiera vos demandes dans ces limites sans demander à chaque fois. Chacun de vous peut y mettre fin."; +"subscriptions__allowance_request_explanation" = "Votre portefeuille paiera les demandes de {name} dans ces limites sans vous demander à chaque fois. Chacun de vous peut y mettre fin."; +"subscriptions__allowance_accept" = "Accepter"; +"subscriptions__allowance_decline" = "Refuser"; +"subscriptions__allowance_swipe_end" = "Glissez pour mettre fin à l'allocation"; +"subscriptions__allowance_swipe_withdraw" = "Glissez pour retirer"; +"subscriptions__allowance_detail_active_allower" = "Les demandes dans ces limites sont payées automatiquement. Au-delà, votre accord vous sera d'abord demandé."; +"subscriptions__allowance_detail_active_allowee" = "Vos demandes dans ces limites sont payées automatiquement."; +"subscriptions__allowance_detail_ended" = "Cette allocation a pris fin. Chaque demande nécessite à nouveau votre accord."; +"subscriptions__allowance_error_not_linked" = "Ce contact n'est pas encore connecté. Réessayez dans un instant."; +"subscriptions__allowance_error_unavailable" = "Cette allocation n'est pas disponible pour le moment."; +"subscriptions__allowance_payment_in_progress" = "Cette demande est déjà en cours de paiement."; +"subscriptions__allowance_executed_title" = "Paiement effectué"; +"subscriptions__allowance_executed_description" = "Le paiement automatique a envoyé {amount} à {name}"; +"subscriptions__allowance_limit_title" = "Limite atteinte"; +"subscriptions__allowance_limit_description" = "Une demande de {amount} de {name} dépasse votre allocation. Examinez-la pour payer."; +"subscriptions__allowance_auto_paid" = "Payé automatiquement"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index a65e4b51e..239133b7a 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1873,3 +1873,50 @@ "usdt__deposit_refund_confirmation" = "Richiedere il rimborso del deposito di {amount} USDT?\n\nRete:\n{network}\n\nIndirizzo di rimborso:\n{address}\n\nVerifica che questo indirizzo possa ricevere USDT su {shortNetwork}. Potrebbero essere applicate commissioni di rimborso."; "usdt__deposit_tron_refund_note" = "I rimborsi TRON possono richiedere l'intervento di un operatore Orchestra."; "usdt__deposit_refund_address_network" = "Indirizzo USDT su {network}"; +"subscriptions__allowances" = "Plafond"; +"subscriptions__allowances_empty_headline" = "Imposta\ni plafond"; +"subscriptions__allowances_empty_description" = "Puoi impostare limiti di spesa per pagare automaticamente le richieste di pagamento dei contatti fidati."; +"subscriptions__allowance_add" = "Aggiungi Plafond"; +"subscriptions__allowance_title" = "Plafond"; +"subscriptions__allowance_choose_contact" = "Scegli Contatto"; +"subscriptions__allowance_no_contacts" = "Aggiungi prima un contatto. I plafond coprono le richieste di pagamento dei tuoi contatti."; +"subscriptions__allowance_set_title" = "Imposta Plafond"; +"subscriptions__allowance_set_explanation" = "Approva automaticamente le richieste di pagamento di {name} entro questi limiti:"; +"subscriptions__allowance_payment_limit" = "Limite per pagamento"; +"subscriptions__allowance_monthly_allowance" = "Plafond mensile"; +"subscriptions__allowance_set_summary" = "Le richieste fino a {perPayment} verranno pagate automaticamente, fino a {monthly} al mese, senza chiederti ogni volta."; +"subscriptions__allowance_save" = "Salva Plafond"; +"subscriptions__allowance_monthly_limit" = "Limite mensile"; +"subscriptions__allowance_per_payment_short" = "{amount} a pagamento"; +"subscriptions__allowance_per_payment" = "Per pagamento"; +"subscriptions__allowance_each_month" = "Ogni mese"; +"subscriptions__allowance_up_to" = "Fino a {amount}"; +"subscriptions__allowance_status_active" = "Attivo"; +"subscriptions__allowance_status_waiting" = "In attesa di risposta"; +"subscriptions__allowance_status_needs_answer" = "In attesa della tua risposta"; +"subscriptions__allowance_status_scheduled" = "Non ancora attivo"; +"subscriptions__allowance_status_expired" = "Scaduto"; +"subscriptions__allowance_status_declined" = "Rifiutato"; +"subscriptions__allowance_status_conflicted" = "Richiede attenzione"; +"subscriptions__allowance_status_ended" = "Terminato"; +"subscriptions__allowance_paid_automatically" = "{amount} pagati automaticamente"; +"subscriptions__allowance_paid_so_far" = "Pagato automaticamente"; +"subscriptions__allowance_offer_headline" = "{name} ti offre\nun plafond"; +"subscriptions__allowance_request_headline" = "{name} chiede\nun plafond"; +"subscriptions__allowance_offer_explanation" = "Il wallet di {name} pagherà le tue richieste entro questi limiti senza chiedere ogni volta. Ognuno di voi può terminarlo."; +"subscriptions__allowance_request_explanation" = "Il tuo wallet pagherà le richieste di {name} entro questi limiti senza chiederti ogni volta. Ognuno di voi può terminarlo."; +"subscriptions__allowance_accept" = "Accetta"; +"subscriptions__allowance_decline" = "Rifiuta"; +"subscriptions__allowance_swipe_end" = "Scorri Per Terminare Il Plafond"; +"subscriptions__allowance_swipe_withdraw" = "Scorri Per Ritirare"; +"subscriptions__allowance_detail_active_allower" = "Le richieste entro questi limiti vengono pagate automaticamente. Per importi superiori ti verrà chiesto prima."; +"subscriptions__allowance_detail_active_allowee" = "Le tue richieste entro questi limiti vengono pagate automaticamente."; +"subscriptions__allowance_detail_ended" = "Questo plafond è terminato. Ogni richiesta richiederà di nuovo la tua conferma."; +"subscriptions__allowance_error_not_linked" = "Questo contatto non è ancora collegato. Riprova tra un momento."; +"subscriptions__allowance_error_unavailable" = "Questo plafond non è disponibile al momento."; +"subscriptions__allowance_payment_in_progress" = "Questa richiesta è già in fase di pagamento."; +"subscriptions__allowance_executed_title" = "Pagamento Eseguito"; +"subscriptions__allowance_executed_description" = "Il pagamento automatico ha inviato {amount} a {name}"; +"subscriptions__allowance_limit_title" = "Limite Raggiunto"; +"subscriptions__allowance_limit_description" = "Una richiesta di {amount} da {name} supera il tuo plafond. Controllala per pagare."; +"subscriptions__allowance_auto_paid" = "Pagato automaticamente"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 5db94734b..b35b29906 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1908,3 +1908,50 @@ "usdt__deposit_refund_confirmation" = "Terugbetaling aanvragen voor storting van {amount} USDT?\n\nNetwerk:\n{network}\n\nTerugbetalingsadres:\n{address}\n\nControleer of dit adres USDT kan ontvangen op {shortNetwork}. Er kunnen terugbetalingskosten gelden."; "usdt__deposit_tron_refund_note" = "Voor TRON-terugbetalingen kan hulp van een Orchestra-operator nodig zijn."; "usdt__deposit_refund_address_network" = "USDT-adres op {network}"; +"subscriptions__allowances" = "Toelagen"; +"subscriptions__allowances_empty_headline" = "Stel\ntoelagen in"; +"subscriptions__allowances_empty_description" = "U kunt bestedingslimieten instellen om betaalverzoeken van vertrouwde contacten automatisch te voldoen."; +"subscriptions__allowance_add" = "Toelage toevoegen"; +"subscriptions__allowance_title" = "Toelage"; +"subscriptions__allowance_choose_contact" = "Contact kiezen"; +"subscriptions__allowance_no_contacts" = "Voeg eerst een contact toe. Toelagen gelden voor betaalverzoeken van uw contacten."; +"subscriptions__allowance_set_title" = "Toelage instellen"; +"subscriptions__allowance_set_explanation" = "Betaalverzoeken van {name} onder deze limieten automatisch goedkeuren:"; +"subscriptions__allowance_payment_limit" = "Betaallimiet"; +"subscriptions__allowance_monthly_allowance" = "Maandelijkse toelage"; +"subscriptions__allowance_set_summary" = "Verzoeken tot {perPayment} worden automatisch betaald, tot {monthly} per maand, zonder het u telkens te vragen."; +"subscriptions__allowance_save" = "Toelage opslaan"; +"subscriptions__allowance_monthly_limit" = "Maandelijkse limiet"; +"subscriptions__allowance_per_payment_short" = "{amount} per betaling"; +"subscriptions__allowance_per_payment" = "Per betaling"; +"subscriptions__allowance_each_month" = "Elke maand"; +"subscriptions__allowance_up_to" = "Tot {amount}"; +"subscriptions__allowance_status_active" = "Actief"; +"subscriptions__allowance_status_waiting" = "Wacht op antwoord"; +"subscriptions__allowance_status_needs_answer" = "Wacht op uw antwoord"; +"subscriptions__allowance_status_scheduled" = "Nog niet actief"; +"subscriptions__allowance_status_expired" = "Verlopen"; +"subscriptions__allowance_status_declined" = "Geweigerd"; +"subscriptions__allowance_status_conflicted" = "Aandacht nodig"; +"subscriptions__allowance_status_ended" = "Beëindigd"; +"subscriptions__allowance_paid_automatically" = "{amount} automatisch betaald"; +"subscriptions__allowance_paid_so_far" = "Automatisch betaald"; +"subscriptions__allowance_offer_headline" = "{name} biedt\neen toelage aan"; +"subscriptions__allowance_request_headline" = "{name} vraagt om\neen toelage"; +"subscriptions__allowance_offer_explanation" = "De wallet van {name} betaalt uw verzoeken binnen deze limieten zonder telkens te vragen. U kunt deze allebei beëindigen."; +"subscriptions__allowance_request_explanation" = "Uw wallet betaalt de verzoeken van {name} binnen deze limieten zonder het u telkens te vragen. U kunt deze allebei beëindigen."; +"subscriptions__allowance_accept" = "Accepteren"; +"subscriptions__allowance_decline" = "Weigeren"; +"subscriptions__allowance_swipe_end" = "Veeg om toelage te beëindigen"; +"subscriptions__allowance_swipe_withdraw" = "Veeg om in te trekken"; +"subscriptions__allowance_detail_active_allower" = "Verzoeken binnen deze limieten worden automatisch betaald. Voor alles daarboven wordt u eerst om toestemming gevraagd."; +"subscriptions__allowance_detail_active_allowee" = "Uw verzoeken binnen deze limieten worden automatisch betaald."; +"subscriptions__allowance_detail_ended" = "Deze toelage is beëindigd. Elk verzoek vraagt weer om toestemming."; +"subscriptions__allowance_error_not_linked" = "Dit contact is nog niet verbonden. Probeer het zo opnieuw."; +"subscriptions__allowance_error_unavailable" = "Deze toelage is momenteel niet beschikbaar."; +"subscriptions__allowance_payment_in_progress" = "Dit verzoek wordt al betaald."; +"subscriptions__allowance_executed_title" = "Betaling uitgevoerd"; +"subscriptions__allowance_executed_description" = "Automatisch betalen heeft {amount} naar {name} gestuurd"; +"subscriptions__allowance_limit_title" = "Limiet bereikt"; +"subscriptions__allowance_limit_description" = "Een verzoek van {amount} van {name} overschrijdt uw toelage. Bekijk het om te betalen."; +"subscriptions__allowance_auto_paid" = "Automatisch betaald"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index afcd7b4a1..b21506d62 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1913,3 +1913,50 @@ "usdt__deposit_refund_confirmation" = "Poprosić o zwrot wpłaty {amount} USDT?\n\nSieć:\n{network}\n\nAdres zwrotu:\n{address}\n\nUpewnij się, że ten adres może odbierać USDT w sieci {shortNetwork}. Mogą obowiązywać opłaty za zwrot."; "usdt__deposit_tron_refund_note" = "Zwroty w sieci TRON mogą wymagać pomocy operatora Orchestra."; "usdt__deposit_refund_address_network" = "Adres USDT w sieci {network}"; +"subscriptions__allowances" = "Przydziały"; +"subscriptions__allowances_empty_headline" = "Ustaw\nprzydziały"; +"subscriptions__allowances_empty_description" = "Możesz ustawić limity wydatków, aby automatycznie realizować żądania płatności od zaufanych kontaktów."; +"subscriptions__allowance_add" = "Dodaj przydział"; +"subscriptions__allowance_title" = "Przydział"; +"subscriptions__allowance_choose_contact" = "Wybierz kontakt"; +"subscriptions__allowance_no_contacts" = "Najpierw dodaj kontakt. Przydziały obejmują żądania płatności od Twoich kontaktów."; +"subscriptions__allowance_set_title" = "Ustaw przydział"; +"subscriptions__allowance_set_explanation" = "Automatycznie zatwierdzaj żądania płatności od {name} poniżej tych limitów:"; +"subscriptions__allowance_payment_limit" = "Limit płatności"; +"subscriptions__allowance_monthly_allowance" = "Miesięczny przydział"; +"subscriptions__allowance_set_summary" = "Żądania do {perPayment} będą opłacane automatycznie, do {monthly} miesięcznie, bez pytania Cię za każdym razem."; +"subscriptions__allowance_save" = "Zapisz przydział"; +"subscriptions__allowance_monthly_limit" = "Limit miesięczny"; +"subscriptions__allowance_per_payment_short" = "{amount} za płatność"; +"subscriptions__allowance_per_payment" = "Za płatność"; +"subscriptions__allowance_each_month" = "Co miesiąc"; +"subscriptions__allowance_up_to" = "Do {amount}"; +"subscriptions__allowance_status_active" = "Aktywny"; +"subscriptions__allowance_status_waiting" = "Czeka na odpowiedź"; +"subscriptions__allowance_status_needs_answer" = "Czeka na Twoją odpowiedź"; +"subscriptions__allowance_status_scheduled" = "Jeszcze nieaktywny"; +"subscriptions__allowance_status_expired" = "Wygasł"; +"subscriptions__allowance_status_declined" = "Odrzucony"; +"subscriptions__allowance_status_conflicted" = "Wymaga uwagi"; +"subscriptions__allowance_status_ended" = "Zakończony"; +"subscriptions__allowance_paid_automatically" = "{amount} opłacono automatycznie"; +"subscriptions__allowance_paid_so_far" = "Opłacono automatycznie"; +"subscriptions__allowance_offer_headline" = "{name} oferuje\nprzydział"; +"subscriptions__allowance_request_headline" = "{name} prosi o\nprzydział"; +"subscriptions__allowance_offer_explanation" = "Portfel {name} będzie opłacać Twoje żądania w tych limitach bez pytania za każdym razem. Każde z was może go zakończyć."; +"subscriptions__allowance_request_explanation" = "Twój portfel będzie opłacać żądania {name} w tych limitach bez pytania Cię za każdym razem. Każde z was może go zakończyć."; +"subscriptions__allowance_accept" = "Akceptuj"; +"subscriptions__allowance_decline" = "Odrzuć"; +"subscriptions__allowance_swipe_end" = "Przesuń, aby zakończyć przydział"; +"subscriptions__allowance_swipe_withdraw" = "Przesuń, aby wycofać"; +"subscriptions__allowance_detail_active_allower" = "Żądania w tych limitach są opłacane automatycznie. O wszystko powyżej zapytamy Cię najpierw."; +"subscriptions__allowance_detail_active_allowee" = "Twoje żądania w tych limitach są opłacane automatycznie."; +"subscriptions__allowance_detail_ended" = "Ten przydział się zakończył. Każde żądanie znów wymaga potwierdzenia."; +"subscriptions__allowance_error_not_linked" = "Ten kontakt nie jest jeszcze połączony. Spróbuj ponownie za chwilę."; +"subscriptions__allowance_error_unavailable" = "Ten przydział jest teraz niedostępny."; +"subscriptions__allowance_payment_in_progress" = "To żądanie jest już opłacane."; +"subscriptions__allowance_executed_title" = "Płatność wykonana"; +"subscriptions__allowance_executed_description" = "Autopłatność wysłała {amount} do {name}"; +"subscriptions__allowance_limit_title" = "Osiągnięto limit"; +"subscriptions__allowance_limit_description" = "Żądanie {amount} od {name} przekracza Twój przydział. Przejrzyj je, aby zapłacić."; +"subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index c84d25a17..bed11492c 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1914,3 +1914,50 @@ "usdt__deposit_refund_confirmation" = "Solicitar reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Taxas de reembolso podem ser cobradas."; "usdt__deposit_tron_refund_note" = "Reembolsos na TRON podem exigir assistência do operador da Orchestra."; "usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}"; +"subscriptions__allowances" = "Pré-aprovações"; +"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações"; +"subscriptions__allowances_empty_description" = "Você pode definir limites de gastos para pagar automaticamente solicitações de pagamento de contatos confiáveis."; +"subscriptions__allowance_add" = "Adicionar Pré-aprovação"; +"subscriptions__allowance_title" = "Pré-aprovação"; +"subscriptions__allowance_choose_contact" = "Escolher Contato"; +"subscriptions__allowance_no_contacts" = "Adicione um contato primeiro. As pré-aprovações cobrem solicitações de pagamento dos seus contatos."; +"subscriptions__allowance_set_title" = "Definir Pré-aprovação"; +"subscriptions__allowance_set_explanation" = "Aprovar automaticamente solicitações de pagamento de {name} abaixo destes limites:"; +"subscriptions__allowance_payment_limit" = "Limite por pagamento"; +"subscriptions__allowance_monthly_allowance" = "Valor mensal"; +"subscriptions__allowance_set_summary" = "Solicitações de até {perPayment} serão pagas automaticamente, até {monthly} por mês, sem perguntar a você toda vez."; +"subscriptions__allowance_save" = "Salvar Pré-aprovação"; +"subscriptions__allowance_monthly_limit" = "Limite mensal"; +"subscriptions__allowance_per_payment_short" = "{amount} por pagamento"; +"subscriptions__allowance_per_payment" = "Por pagamento"; +"subscriptions__allowance_each_month" = "Todo mês"; +"subscriptions__allowance_up_to" = "Até {amount}"; +"subscriptions__allowance_status_active" = "Ativa"; +"subscriptions__allowance_status_waiting" = "Aguardando resposta"; +"subscriptions__allowance_status_needs_answer" = "Aguardando sua resposta"; +"subscriptions__allowance_status_scheduled" = "Ainda não ativa"; +"subscriptions__allowance_status_expired" = "Expirada"; +"subscriptions__allowance_status_declined" = "Recusada"; +"subscriptions__allowance_status_conflicted" = "Requer atenção"; +"subscriptions__allowance_status_ended" = "Encerrada"; +"subscriptions__allowance_paid_automatically" = "{amount} pago automaticamente"; +"subscriptions__allowance_paid_so_far" = "Pago automaticamente"; +"subscriptions__allowance_offer_headline" = "{name} oferece\numa pré-aprovação"; +"subscriptions__allowance_request_headline" = "{name} pede\numa pré-aprovação"; +"subscriptions__allowance_offer_explanation" = "A carteira de {name} pagará suas solicitações dentro destes limites sem perguntar toda vez. Qualquer um de vocês pode encerrá-la."; +"subscriptions__allowance_request_explanation" = "Sua carteira pagará as solicitações de {name} dentro destes limites sem perguntar a você toda vez. Qualquer um de vocês pode encerrá-la."; +"subscriptions__allowance_accept" = "Aceitar"; +"subscriptions__allowance_decline" = "Recusar"; +"subscriptions__allowance_swipe_end" = "Deslize para Encerrar a Pré-aprovação"; +"subscriptions__allowance_swipe_withdraw" = "Deslize para Retirar"; +"subscriptions__allowance_detail_active_allower" = "Solicitações dentro destes limites são pagas automaticamente. Qualquer valor acima pede sua confirmação antes."; +"subscriptions__allowance_detail_active_allowee" = "Suas solicitações dentro destes limites são pagas automaticamente."; +"subscriptions__allowance_detail_ended" = "Esta pré-aprovação foi encerrada. Cada solicitação volta a pedir confirmação."; +"subscriptions__allowance_error_not_linked" = "Este contato ainda não está conectado. Tente novamente em instantes."; +"subscriptions__allowance_error_unavailable" = "Esta pré-aprovação não está disponível no momento."; +"subscriptions__allowance_payment_in_progress" = "Esta solicitação já está sendo paga."; +"subscriptions__allowance_executed_title" = "Pagamento Executado"; +"subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; +"subscriptions__allowance_limit_title" = "Limite Atingido"; +"subscriptions__allowance_limit_description" = "Uma solicitação de {amount} de {name} excede sua pré-aprovação. Revise-a para pagar."; +"subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 3ba9289c8..6d1c1666a 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1560,3 +1560,50 @@ "usdt__deposit_refund_confirmation" = "Pedir o reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Podem aplicar-se taxas de reembolso."; "usdt__deposit_tron_refund_note" = "Os reembolsos na TRON podem exigir assistência do operador da Orchestra."; "usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}"; +"subscriptions__allowances" = "Pré-aprovações"; +"subscriptions__allowances_empty_headline" = "Configure\npré-aprovações"; +"subscriptions__allowances_empty_description" = "Pode definir limites de gastos para pagar automaticamente pedidos de pagamento de contactos de confiança."; +"subscriptions__allowance_add" = "Adicionar pré-aprovação"; +"subscriptions__allowance_title" = "Pré-aprovação"; +"subscriptions__allowance_choose_contact" = "Escolher contacto"; +"subscriptions__allowance_no_contacts" = "Adicione primeiro um contacto. As pré-aprovações abrangem pedidos de pagamento dos seus contactos."; +"subscriptions__allowance_set_title" = "Definir pré-aprovação"; +"subscriptions__allowance_set_explanation" = "Aprovar automaticamente pedidos de pagamento de {name} abaixo destes limites:"; +"subscriptions__allowance_payment_limit" = "Limite por pagamento"; +"subscriptions__allowance_monthly_allowance" = "Valor mensal"; +"subscriptions__allowance_set_summary" = "Os pedidos até {perPayment} serão pagos automaticamente, até {monthly} por mês, sem lhe perguntar sempre."; +"subscriptions__allowance_save" = "Guardar pré-aprovação"; +"subscriptions__allowance_monthly_limit" = "Limite mensal"; +"subscriptions__allowance_per_payment_short" = "{amount} por pagamento"; +"subscriptions__allowance_per_payment" = "Por pagamento"; +"subscriptions__allowance_each_month" = "Todos os meses"; +"subscriptions__allowance_up_to" = "Até {amount}"; +"subscriptions__allowance_status_active" = "Ativa"; +"subscriptions__allowance_status_waiting" = "A aguardar resposta"; +"subscriptions__allowance_status_needs_answer" = "A aguardar a sua resposta"; +"subscriptions__allowance_status_scheduled" = "Ainda não ativa"; +"subscriptions__allowance_status_expired" = "Expirada"; +"subscriptions__allowance_status_declined" = "Recusada"; +"subscriptions__allowance_status_conflicted" = "Requer atenção"; +"subscriptions__allowance_status_ended" = "Terminada"; +"subscriptions__allowance_paid_automatically" = "{amount} pago automaticamente"; +"subscriptions__allowance_paid_so_far" = "Pago automaticamente"; +"subscriptions__allowance_offer_headline" = "{name} oferece-lhe\numa pré-aprovação"; +"subscriptions__allowance_request_headline" = "{name} pede\numa pré-aprovação"; +"subscriptions__allowance_offer_explanation" = "A carteira de {name} pagará os seus pedidos dentro destes limites sem perguntar sempre. Qualquer um de vós pode terminá-la."; +"subscriptions__allowance_request_explanation" = "A sua carteira pagará os pedidos de {name} dentro destes limites sem lhe perguntar sempre. Qualquer um de vós pode terminá-la."; +"subscriptions__allowance_accept" = "Aceitar"; +"subscriptions__allowance_decline" = "Recusar"; +"subscriptions__allowance_swipe_end" = "Deslize para terminar a pré-aprovação"; +"subscriptions__allowance_swipe_withdraw" = "Deslize para retirar"; +"subscriptions__allowance_detail_active_allower" = "Os pedidos dentro destes limites são pagos automaticamente. Para valores acima, ser-lhe-á pedida confirmação primeiro."; +"subscriptions__allowance_detail_active_allowee" = "Os seus pedidos dentro destes limites são pagos automaticamente."; +"subscriptions__allowance_detail_ended" = "Esta pré-aprovação terminou. Cada pedido volta a pedir confirmação."; +"subscriptions__allowance_error_not_linked" = "Este contacto ainda não está ligado. Tente novamente daqui a pouco."; +"subscriptions__allowance_error_unavailable" = "Esta pré-aprovação não está disponível de momento."; +"subscriptions__allowance_payment_in_progress" = "Este pedido já está a ser pago."; +"subscriptions__allowance_executed_title" = "Pagamento executado"; +"subscriptions__allowance_executed_description" = "O pagamento automático enviou {amount} para {name}"; +"subscriptions__allowance_limit_title" = "Limite atingido"; +"subscriptions__allowance_limit_description" = "Um pedido de {amount} de {name} excede a sua pré-aprovação. Reveja-o para pagar."; +"subscriptions__allowance_auto_paid" = "Pago automaticamente"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index a332ce783..c8cc453c7 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1894,3 +1894,50 @@ "usdt__deposit_refund_confirmation" = "Запросить возврат депозита {amount} USDT?\n\nСеть:\n{network}\n\nАдрес для возврата:\n{address}\n\nУбедитесь, что этот адрес может получать USDT в сети {shortNetwork}. Может взиматься комиссия за возврат."; "usdt__deposit_tron_refund_note" = "Для возвратов в сети TRON может потребоваться помощь оператора Orchestra."; "usdt__deposit_refund_address_network" = "Адрес USDT в сети {network}"; +"subscriptions__allowances" = "Лимиты автооплаты"; +"subscriptions__allowances_empty_headline" = "Настройте\nлимиты автооплаты"; +"subscriptions__allowances_empty_description" = "Вы можете задать лимиты расходов, чтобы автоматически оплачивать запросы на оплату от доверенных контактов."; +"subscriptions__allowance_add" = "Добавить Лимит"; +"subscriptions__allowance_title" = "Лимит автооплаты"; +"subscriptions__allowance_choose_contact" = "Выберите Контакт"; +"subscriptions__allowance_no_contacts" = "Сначала добавьте контакт. Лимиты автооплаты действуют для запросов на оплату от ваших контактов."; +"subscriptions__allowance_set_title" = "Задать Лимит"; +"subscriptions__allowance_set_explanation" = "Автоматически одобрять запросы на оплату от {name} в пределах этих лимитов:"; +"subscriptions__allowance_payment_limit" = "Лимит платежа"; +"subscriptions__allowance_monthly_allowance" = "Месячный лимит автооплаты"; +"subscriptions__allowance_set_summary" = "Запросы до {perPayment} будут оплачиваться автоматически, но не более {monthly} в месяц, без подтверждения каждый раз."; +"subscriptions__allowance_save" = "Сохранить Лимит"; +"subscriptions__allowance_monthly_limit" = "Месячный лимит"; +"subscriptions__allowance_per_payment_short" = "{amount} за платёж"; +"subscriptions__allowance_per_payment" = "За платёж"; +"subscriptions__allowance_each_month" = "Каждый месяц"; +"subscriptions__allowance_up_to" = "До {amount}"; +"subscriptions__allowance_status_active" = "Активен"; +"subscriptions__allowance_status_waiting" = "Ожидает ответа"; +"subscriptions__allowance_status_needs_answer" = "Ожидает вашего ответа"; +"subscriptions__allowance_status_scheduled" = "Ещё не активен"; +"subscriptions__allowance_status_expired" = "Истёк"; +"subscriptions__allowance_status_declined" = "Отклонён"; +"subscriptions__allowance_status_conflicted" = "Требует внимания"; +"subscriptions__allowance_status_ended" = "Завершён"; +"subscriptions__allowance_paid_automatically" = "{amount} оплачено автоматически"; +"subscriptions__allowance_paid_so_far" = "Оплачено автоматически"; +"subscriptions__allowance_offer_headline" = "{name} предлагает\nлимит автооплаты"; +"subscriptions__allowance_request_headline" = "{name} просит\nлимит автооплаты"; +"subscriptions__allowance_offer_explanation" = "Кошелёк {name} будет оплачивать ваши запросы в пределах этих лимитов без подтверждения каждый раз. Любой из вас может его завершить."; +"subscriptions__allowance_request_explanation" = "Ваш кошелёк будет оплачивать запросы {name} в пределах этих лимитов, не спрашивая вас каждый раз. Любой из вас может его завершить."; +"subscriptions__allowance_accept" = "Принять"; +"subscriptions__allowance_decline" = "Отклонить"; +"subscriptions__allowance_swipe_end" = "Проведите для Завершения"; +"subscriptions__allowance_swipe_withdraw" = "Проведите для Отзыва"; +"subscriptions__allowance_detail_active_allower" = "Запросы в пределах этих лимитов оплачиваются автоматически. Всё, что выше, сначала потребует вашего подтверждения."; +"subscriptions__allowance_detail_active_allowee" = "Ваши запросы в пределах этих лимитов оплачиваются автоматически."; +"subscriptions__allowance_detail_ended" = "Этот лимит автооплаты завершён. Каждый запрос снова требует подтверждения."; +"subscriptions__allowance_error_not_linked" = "Этот контакт ещё не подключён. Повторите попытку через минуту."; +"subscriptions__allowance_error_unavailable" = "Этот лимит автооплаты сейчас недоступен."; +"subscriptions__allowance_payment_in_progress" = "Этот запрос уже оплачивается."; +"subscriptions__allowance_executed_title" = "Платёж Выполнен"; +"subscriptions__allowance_executed_description" = "Автооплата отправила {amount} пользователю {name}"; +"subscriptions__allowance_limit_title" = "Лимит Достигнут"; +"subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить."; +"subscriptions__allowance_auto_paid" = "Оплачено автоматически"; diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift new file mode 100644 index 000000000..aece2ece2 --- /dev/null +++ b/Bitkit/Services/PaykitAllowance.swift @@ -0,0 +1,341 @@ +import Foundation +import Paykit + +/// An Allowance between this wallet's identity and one contact's identity, built from the SDK record. +/// Eligibility runs on the trusted time passed in by the caller and never on `SubscriptionClock`, so a clock offset cannot move an allowance window. +struct PaykitAllowance: Identifiable, Hashable { + struct ID: Codable, Hashable { + let counterparty: String + let allowanceId: String + } + + enum Role: String, Codable, Hashable { + case allower + case allowee + } + + enum Status: Hashable { + /// Sent by this wallet; the other side has not answered yet. + case awaitingAnswer + /// Received; this wallet must accept or decline. + case awaitingMyAnswer + case active + case notYetActive + case expired + case declined + case ended + case conflicted + } + + let id: ID + let role: Role + let lifecycleState: Paykit.AllowanceLifecycleState + let isProposedByMe: Bool + let perPaymentMaxSats: UInt64? + let monthlyLimitSats: UInt64? + let monthlyAnchor: Date? + let activeFrom: Date? + let expiresAt: Date? + let allowedPaymentEndpointIdentifiers: [String]? + let lastEventAt: Date? + /// The proposal's stream item as displayed; acceptance names it so a re-sent proposal asks again. + let proposalStreamItemId: UInt64? + + var counterparty: String { + id.counterparty + } + + var allowanceId: String { + id.allowanceId + } + + /// Allowance ids are unique only within one link, so local state keys an allowance by its counterparty too. + var scopedId: String { + Self.scopedId(counterparty: counterparty, allowanceId: allowanceId) + } + + static func scopedId(counterparty: String, allowanceId: String) -> String { + "\(PubkyPublicKeyFormat.normalized(counterparty) ?? counterparty)/\(allowanceId)" + } + + init?(record: Paykit.AllowanceRecord) { + guard let localRole = record.localRole, + let role = Role(localRole), + let terms = record.terms, + terms.asset() == PaykitIssuerInterop.bitcoinAsset + else { return nil } + + let monthly = terms.periodLimits().first { Self.isMonthly($0.period()) } + let perPaymentMaxSats = terms.perPaymentAmount().flatMap { Self.sats(fromBitcoinAmount: $0.maximum()) } + let monthlyLimitSats = monthly?.amountLimit().flatMap { Self.sats(fromBitcoinAmount: $0) } + let monthlyAnchor = monthly?.period().anchor().flatMap(PaykitAllowanceTime.parse) + // This wallet pays only under limits it can show: terms whose caps don't parse to sats with a monthly anchor are dropped. + if role == .allower, perPaymentMaxSats == nil || monthlyLimitSats == nil || monthlyAnchor == nil { + return nil + } + id = ID(counterparty: record.counterparty, allowanceId: record.allowanceId) + self.role = role + lifecycleState = record.state + isProposedByMe = record.proposalOutboundMessageId != nil + self.perPaymentMaxSats = perPaymentMaxSats + self.monthlyLimitSats = monthlyLimitSats + self.monthlyAnchor = monthlyAnchor + activeFrom = terms.activeFrom().flatMap(PaykitAllowanceTime.parse) + expiresAt = terms.expiresAt().flatMap(PaykitAllowanceTime.parse) + allowedPaymentEndpointIdentifiers = terms.allowedPaymentEndpointIdentifiers() + lastEventAt = record.lastEventAt.flatMap(PaykitAllowanceTime.parse) + proposalStreamItemId = record.proposalStreamItemId + } + + init( + id: ID, + role: Role, + lifecycleState: Paykit.AllowanceLifecycleState, + isProposedByMe: Bool, + perPaymentMaxSats: UInt64?, + monthlyLimitSats: UInt64?, + monthlyAnchor: Date?, + activeFrom: Date? = nil, + expiresAt: Date? = nil, + allowedPaymentEndpointIdentifiers: [String]? = nil, + lastEventAt: Date? = nil, + proposalStreamItemId: UInt64? = nil + ) { + self.id = id + self.role = role + self.lifecycleState = lifecycleState + self.isProposedByMe = isProposedByMe + self.perPaymentMaxSats = perPaymentMaxSats + self.monthlyLimitSats = monthlyLimitSats + self.monthlyAnchor = monthlyAnchor + self.activeFrom = activeFrom + self.expiresAt = expiresAt + self.allowedPaymentEndpointIdentifiers = allowedPaymentEndpointIdentifiers + self.lastEventAt = lastEventAt + self.proposalStreamItemId = proposalStreamItemId + } + + func status(at now: Date) -> Status { + switch lifecycleState { + case .proposed: + return isProposedByMe ? .awaitingAnswer : .awaitingMyAnswer + case .accepted: + if let expiresAt, now >= expiresAt { + return .expired + } + if let activeFrom, now < activeFrom { + return .notYetActive + } + return .active + case .rejected: + return .declined + case .ended: + return .ended + case .conflicted, .unknown: + return .conflicted + } + } + + /// The payer side: this wallet pays the counterparty's requests automatically. + var isAllower: Bool { + role == .allower + } + + var canEnd: Bool { + switch lifecycleState { + case .accepted: true + case .proposed: isProposedByMe + default: false + } + } + + var isAnswerable: Bool { + lifecycleState == .proposed && !isProposedByMe + } + + static func isMonthly(_ period: Paykit.AllowancePeriod) -> Bool { + period.kind() == "anchored" && period.every() == 1 && period.unit() == "month" + } + + static func sats(fromBitcoinAmount amount: String) -> UInt64? { + if amount.split(separator: ".").allSatisfy({ $0.allSatisfy { $0 == "0" } }) { + return 0 + } + return PaykitPaymentRequest.sats(fromBitcoinAmount: amount) + } +} + +extension Paykit.AllowanceLifecycleState { + var rawDescription: String { + switch self { + case .proposed: "proposed" + case .accepted: "accepted" + case .rejected: "rejected" + case .ended: "ended" + case .conflicted: "conflicted" + case .unknown: "unknown" + } + } +} + +extension PaykitAllowance.Role { + init?(_ role: Paykit.AllowanceLocalRole) { + switch role { + case .allower: self = .allower + case .allowee: self = .allowee + case .unknown: return nil + } + } +} + +/// Limits picked in USD on the Set Allowance sheet, converted once to whole-sat BTC terms. +struct PaykitAllowanceLimits: Codable, Hashable { + let perPaymentUsd: Decimal + let monthlyUsd: Decimal + let perPaymentSats: UInt64 + let monthlySats: UInt64 + + static let perPaymentStopsUsd: [Decimal] = [1, 5, 10, 20, 50] + static let monthlyStopsUsd: [Decimal] = [10, 50, 100, 200, 500] + + /// Terms Bitkit proposes: per-payment range 0...max, an anchored UTC calendar month, and the endpoints Bitkit pays. + func terms(monthAnchor: Date, allowedPaymentEndpointIdentifiers: [String]) throws -> Paykit.AllowanceTerms { + let perPayment = try Paykit.AllowanceAmountRange( + minimum: "0", + maximum: WalletViewModel.formatBitcoinAmount(sats: perPaymentSats) + ) + let month = try Paykit.AllowancePeriod( + kind: "anchored", + every: 1, + unit: "month", + anchor: PaykitAllowanceTime.format(monthAnchor) + ) + let monthly = try Paykit.AllowancePeriodLimit( + amountLimit: WalletViewModel.formatBitcoinAmount(sats: monthlySats), + paymentCountLimit: nil, + period: month + ) + return try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: perPayment, + periodLimits: [monthly], + lifetimeAmountLimit: nil, + activeFrom: nil, + expiresAt: nil, + allowedPaymentEndpointIdentifiers: allowedPaymentEndpointIdentifiers + ) + } +} + +enum PaykitAllowanceTime { + static func format(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: date) + } + + static func parse(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + /// First instant of the UTC calendar month that contains `date`. + static func monthStart(containing date: Date) -> Date { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let components = calendar.dateComponents([.year, .month], from: date) + return calendar.date(from: components) ?? date + } + + /// The anchored monthly window `[start, end)` that contains `date`. Anchors on a day that a short month lacks + /// clamp to that month's last day, as the spec's anchored-period arithmetic does. + static func monthlyWindow(anchor: Date, containing date: Date) -> (start: Date, end: Date) { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + let boundary: (Int) -> Date = { calendar.date(byAdding: .month, value: $0, to: anchor) ?? anchor } + let anchorMonth = calendar.dateComponents([.year, .month], from: anchor) + let dateMonth = calendar.dateComponents([.year, .month], from: date) + // Every boundary counts from the original anchor, so a clamped February never shortens later months. + var index = ((dateMonth.year ?? 0) - (anchorMonth.year ?? 0)) * 12 + (dateMonth.month ?? 0) - (anchorMonth.month ?? 0) + while boundary(index) > date { + index -= 1 + } + while boundary(index + 1) <= date { + index += 1 + } + return (boundary(index), boundary(index + 1)) + } +} + +/// Wallet-side capacity preflight. The SDK checks capacity only after automatic Acceptance, so Bitkit sums this +/// Allowance's live automatic attempts in the current month first and keeps an over-cap request on the manual flow. +enum PaykitAllowanceCapacity { + struct Attempt: Equatable { + let counterparty: String + let allowanceId: String + let amountSats: UInt64 + let admittedAt: Date + let isLive: Bool + } + + /// Allowance ids are unique only within one link, so attempts count toward an allowance only under its counterparty. + static func usedSats(counterparty: String, allowanceId: String, attempts: [Attempt], anchor: Date, now: Date) -> UInt64 { + let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: now) + return attempts + .filter { + $0.allowanceId == allowanceId && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) && $0.isLive && + $0.admittedAt >= window.start && $0.admittedAt < window.end + } + .reduce(0) { $0 + $1.amountSats } + } + + static func fits(amountSats: UInt64, allowance: PaykitAllowance, attempts: [Attempt], now: Date) -> Bool { + if let perPaymentMaxSats = allowance.perPaymentMaxSats, amountSats > perPaymentMaxSats { + return false + } + // Terms without both ceilings never pay automatically, whatever the SDK accepts. + guard allowance.perPaymentMaxSats != nil, let monthlyLimitSats = allowance.monthlyLimitSats, let anchor = allowance.monthlyAnchor else { + return false + } + let used = usedSats(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId, attempts: attempts, anchor: anchor, now: now) + return used + amountSats <= monthlyLimitSats + } + + static func attempts(from history: Paykit.AllowanceAccountingHistory) -> [Attempt] { + history.occurrences.flatMap { occurrence in + occurrence.attempts.map { (counterparty: occurrence.key.request.counterparty, attempt: $0) } + }.compactMap { counterparty, attempt in + guard attempt.mode == .automatic, + let allowanceId = attempt.allowanceId, + let admittedAt = PaykitAllowanceTime.parse(attempt.admittedAt), + let amountSats = PaykitAllowance.sats(fromBitcoinAmount: attempt.amount.value()) + else { return nil } + return Attempt( + counterparty: counterparty, + allowanceId: allowanceId, + amountSats: amountSats, + admittedAt: admittedAt, + isLive: attempt.status != .failed + ) + } + } +} + +extension PaykitPaymentRequest { + /// Allowances cover only requests priced in bitcoin, paid over a bitcoin rail. A conversion only prices other rails, and + /// the SDK requires a bitcoin rail to pay the bitcoin amount, so it does not keep a request manual. + var isAllowancePayable: Bool { + amount.asset == .btc + } + + /// The requested amount in sats, read only for a request where `isAllowancePayable` holds. + var allowanceAmountSats: UInt64 { + amount.atomic + } +} diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift new file mode 100644 index 000000000..812f41938 --- /dev/null +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -0,0 +1,999 @@ +import Combine +import Foundation +import LDKNode +import Paykit + +protocol PaykitAllowanceSdkHandling: Sendable { + func linkedPeers() async throws -> [LinkedPeerRecord] + func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] + func proposeAllowance( + counterparty: String, + localRole: Paykit.AllowanceLocalRole, + terms: Paykit.AllowanceTerms + ) async throws -> Paykit.AllowanceRecord + func acceptAllowance(counterparty: String, allowanceId: String, expectedProposalStreamItemId: UInt64) async throws -> Paykit.AllowanceRecord + func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord + func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord + @discardableResult + func receivePrivateMessages(counterparty: String) async throws -> Paykit.PrivateStreamIntakeReport + @discardableResult + func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport + func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? + func reconcileAllowanceAccounting(_ reconciliation: Paykit.AllowanceAccountingReconciliation) async throws -> Paykit.AllowanceAccountingState + func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] + func acceptPaymentRequestAutomatically( + scope: Paykit.PaymentRequestScope, + selection: Paykit.AllowanceSelectionInput, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.AllowanceAssociationRecord + func reserveAutomaticPayment( + occurrence: Paykit.PaymentOccurrence, + expectedAssociationRevision: UInt64, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision + func reserveManualPayment(occurrence: Paykit.PaymentOccurrence, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit + .PaymentAttemptDecision + func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision + @discardableResult + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord + @discardableResult + func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord +} + +extension PaykitSdkService: PaykitAllowanceSdkHandling {} + +/// Local Allowance state kept per identity: the USD labels of each grant, and the execution journal that restart +/// recovery reads. The SDK ledger stays authoritative for admission. +struct PaykitAllowanceLocalState: Codable, Equatable { + struct Group: Codable, Equatable { + let id: String + let counterparty: String + let limits: PaykitAllowanceLimits + var allowanceIds: [String] + let createdAt: Date + } + + enum Stage: String, Codable { + case prepared + case submitted + case sending + case sent + case succeeded + case failed + case unknown + + var isTerminal: Bool { + self == .succeeded || self == .failed + } + } + + struct JournalEntry: Codable, Equatable { + let attemptId: String + let isAutomatic: Bool + let requestId: PaykitPaymentRequest.ID + let allowanceId: String? + let amountSats: UInt64 + let paymentEndpointIdentifier: String + var paymentHash: String? + var onchainAddress: String? + var transactionId: String? + var stage: Stage + let createdAt: Date + /// The payee's payment list this attempt consumed and the one it replaced, so a failure releases it after a restart. + var paymentListVersion: UInt64? = nil + var previousPaymentListVersion: UInt64? = nil + } + + var groups: [Group] = [] + var journal: [JournalEntry] = [] + var presentedProposalIds: Set = [] + var notifiedRequestIds: Set = [] + var lastTrustedTime: Date? + + /// Allowance ids are unique only within one link, so a group also matches its counterparty. + func group(containing allowanceId: String, counterparty: String) -> Group? { + groups.first { $0.allowanceIds.contains(allowanceId) && PubkyPublicKeyFormat.matches($0.counterparty, counterparty) } + } +} + +protocol PaykitAllowanceStoring: Sendable { + func load(identity: String) throws -> PaykitAllowanceLocalState + func save(_ state: PaykitAllowanceLocalState, identity: String) throws +} + +struct PaykitAllowanceKeychainStore: PaykitAllowanceStoring { + private typealias Stored = [String: PaykitAllowanceLocalState] + + func load(identity: String) throws -> PaykitAllowanceLocalState { + try loadAll()[identity] ?? PaykitAllowanceLocalState() + } + + func save(_ state: PaykitAllowanceLocalState, identity: String) throws { + var all = try loadAll() + all[identity] = state + try Keychain.upsert(key: .paykitAllowanceState, data: JSONEncoder().encode(all)) + } + + private func loadAll() throws -> Stored { + guard let data = try Keychain.load(key: .paykitAllowanceState) else { return [:] } + do { + return try JSONDecoder().decode(Stored.self, from: data) + } catch { + Logger.warn("Discarding invalid Paykit allowance state: \(error)", context: "PaykitAllowance") + return [:] + } + } +} + +/// The side effects of paying one request, behind a protocol so the admission logic is testable without a node. +protocol PaykitAllowancePaying: Sendable { + func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? + func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws + func resolvePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID, outcome: PrivatePaymentListSendOutcome) async + func claimForExecution(_ request: PaykitPaymentRequest) async throws + /// The list version a consumption replaced, read while its in-memory record still exists. + func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? + /// Rolls back a list a failed automatic payment consumed, when it is still the contact's consumed version. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async + /// Keeps this device the local owner of the request across the automatic Acceptance, as the manual flow does, so a + /// request that was accepted but never paid comes back to the payer as an ordinary Payment Request. + /// Returns whether this call added the intent, so a refused acceptance never drops one the manual flow saved. + @discardableResult + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool + func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws + func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws + func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws + func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws + /// Removes the proof only when the submission error proves nothing reached the node; returns whether it did. + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool + func cancelProofPreparation(_ request: PaykitPaymentRequest) async +} + +struct PaykitAllowanceLivePayer: PaykitAllowancePaying { + func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? { + try await PrivatePaykitService.shared.resolveAllowancePayment(request, eligibleIdentifiers: eligibleIdentifiers) + } + + func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { + try await PrivatePaykitService.shared.consumePrivatePaymentList(publicKey: publicKey, context: context, attemptId: attemptId) + } + + func resolvePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + outcome: PrivatePaymentListSendOutcome + ) async { + do { + try await PrivatePaykitService.shared.resolvePrivatePaymentListConsumption( + publicKey: publicKey, + context: context, + attemptId: attemptId, + outcome: outcome + ) + } catch { + Logger.error("Failed to resolve private Paykit payment list consumption: \(error)", context: "PaykitAllowance") + } + } + + func claimForExecution(_ request: PaykitPaymentRequest) async throws { + try await PaykitPaymentRequestService().claimForPayment(request) + } + + func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { + await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: publicKey, attemptId: attemptId) + } + + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + do { + try await PrivatePaykitService.shared.releaseConsumedPaymentList(publicKey: publicKey, version: version, previousVersion: previousVersion) + } catch { + Logger.warn("Failed to release a payment list a failed automatic payment consumed: \(error)", context: "PaykitAllowance") + } + } + + @discardableResult + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool { + let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + var ids = try store.load(identity: identity) + guard ids.insert(request.id).inserted else { return false } + try store.save(ids, identity: identity) + return true + } + + func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async { + let store = PaykitPaymentRequestIdStore(key: .paykitAcceptedPaymentRequests) + do { + try store.save(store.load(identity: identity).subtracting([request.id]), identity: identity) + } catch { + Logger.warn("Failed to discard an allowance acceptance intent: \(error)", context: "PaykitAllowance") + } + } + + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + try await PaykitPaymentRequestService().ensurePaymentAllowed(request) + } + + func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws { + guard let kind = PaykitPaymentProofKind(paymentEndpointIdentifier: paymentEndpointIdentifier) else { + throw PaykitPaymentRequestError.requestUnavailable + } + try await PaykitPaymentProofService.shared.prepare( + request: request, + paymentAppId: paymentAppId, + paymentEndpointIdentifier: paymentEndpointIdentifier, + kind: kind, + allowanceId: allowanceId + ) + } + + func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + try await PaykitPaymentProofService.shared.associateLightningPayment(request, paymentHash: paymentHash) + } + + /// Routing fees fall outside the allowance's limits, so an automatic payment may spend at most 1% of its amount on them. + static func routeParameters(amountSats: UInt64) -> RouteParametersConfig { + RouteParametersConfig( + maxTotalRoutingFeeMsat: amountSats * 10, + maxTotalCltvExpiryDelta: 1008, + maxPathCount: 10, + maxChannelSaturationPowerOfHalf: 2 + ) + } + + func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws { + _ = try await LightningService.shared.send( + bolt11: bolt11, + sats: sats, + params: Self.routeParameters(amountSats: request.allowanceAmountSats), + beforeSubmission: { try PaykitPaymentRequest.checkPaymentDeadline(request.paymentDeadline) } + ) + } + + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash, submissionError: submissionError) + } + + func cancelProofPreparation(_ request: PaykitPaymentRequest) async { + await PaykitPaymentProofService.shared.cancelPreparation(request) + } +} + +enum PaykitAllowanceEvent: Equatable { + case paidAutomatically(counterparty: String, amountSats: UInt64, paymentId: String) + case limitReached(counterparty: String, amountSats: UInt64) + case ledgerChanged +} + +enum PaykitAllowanceAutoPayResult: Equatable { + /// No accepted Allowance covers the request's link. + case notCovered + /// An Allowance exists but this request stays on the manual flow (over a limit, ended, no payable endpoint). + case manual + /// The payment was handed to the node; the outcome arrives through the payment events. + case started + /// The payee has not published a payment list newer than the one last paid; the next refresh tries again. + case deferred +} + +enum PaykitAllowanceManualPaymentError: LocalizedError { + case alreadyRecorded + + var errorDescription: String? { + t("subscriptions__allowance_payment_in_progress") + } +} + +/// Runs Allowance admission for incoming requests through the SDK: evaluate, capacity preflight, automatic +/// Acceptance, reserve, begin, pay, record the outcome. The wallet journals every attempt before handoff so a +/// restart resolves it from the node instead of paying again. +actor PaykitAllowanceExecutor { + static let shared = PaykitAllowanceExecutor() + + private static let eventSubject = PassthroughSubject() + + nonisolated static var eventPublisher: AnyPublisher { + eventSubject.eraseToAnyPublisher() + } + + private let sdk: any PaykitAllowanceSdkHandling + private let store: any PaykitAllowanceStoring + private let payer: any PaykitAllowancePaying + private let lightningLookup: any PaykitLightningPaymentProofLookingUp + private let now: @Sendable () -> Date + private var inFlightRequestIds = Set() + private(set) var activeIdentity: String? + + init( + sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, + store: any PaykitAllowanceStoring = PaykitAllowanceKeychainStore(), + payer: any PaykitAllowancePaying = PaykitAllowanceLivePayer(), + lightningLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(), + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.sdk = sdk + self.store = store + self.payer = payer + self.lightningLookup = lightningLookup + self.now = now + } + + func activate(identity: String?) { + activeIdentity = identity + } + + // MARK: Local state + + func localState(identity: String) -> PaykitAllowanceLocalState { + (try? store.load(identity: identity)) ?? PaykitAllowanceLocalState() + } + + func updateLocalState(identity: String, _ change: (inout PaykitAllowanceLocalState) -> Void) { + var state = localState(identity: identity) + change(&state) + do { + try store.save(state, identity: identity) + } catch { + Logger.warn("Failed to save Paykit allowance state: \(error)", context: "PaykitAllowance") + } + } + + func isHandling(_ requestId: PaykitPaymentRequest.ID) -> Bool { + inFlightRequestIds.contains(requestId) + } + + /// Trusted time for eligibility: the real clock, never earlier than the last value given to the SDK, + /// because the SDK refuses a watermark that moves backwards. + func trustedTime(identity: String) -> String { + var date = now() + updateLocalState(identity: identity) { state in + if let last = state.lastTrustedTime, last > date { + date = last + } + state.lastTrustedTime = date + } + return PaykitAllowanceTime.format(date) + } + + // MARK: Ledger + + /// Brings the SDK ledger to a reconciled state. A wallet with no ledger attests an empty history: it has never + /// paid through an Allowance. After a restore, outcomes come from the journal and the node. + @discardableResult + func ensureReconciled(identity: String) async throws -> Paykit.AllowanceAccountingState { + let current = try await sdk.allowanceAccountingState() + if let current, !current.requiresReconciliation { + return current + } + + let history = current?.history ?? Paykit.AllowanceAccountingHistory(associations: [], occurrences: [], watermarks: []) + var outcomes: [Paykit.PaymentOutcomeReport] = [] + for attempt in history.occurrences.flatMap(\.attempts) { + if let outcome = await verifiedOutcome(for: attempt, identity: identity) { + outcomes.append(Paykit.PaymentOutcomeReport(attemptId: attempt.attemptId, outcome: outcome)) + } + } + let reconciled = try await sdk.reconcileAllowanceAccounting( + Paykit.AllowanceAccountingReconciliation( + expectedRevision: current?.revision, + history: history, + outcomes: outcomes, + trustedTime: trustedTime(identity: identity) + ) + ) + Logger.info("Reconciled Paykit allowance accounting with \(outcomes.count) verified outcomes", context: "PaykitAllowance") + return reconciled + } + + /// Resolves attempts a crash or kill left open. Prepared attempts never got a handoff and are released; + /// submitted ones are settled from the node. Nothing is paid again. + func recover(identity: String) async { + do { + // No ledger means nothing was ever admitted, so there is nothing to recover and no reason to create one at launch. + guard try await sdk.allowanceAccountingState() != nil else { return } + let state = try await ensureReconciled(identity: identity) + // One journal read for the whole pass: each attempt appears once, so a stage changed below is never read again. + let journalStages = Dictionary( + localState(identity: identity).journal.map { ($0.attemptId, $0.stage) }, + uniquingKeysWith: { first, _ in first } + ) + for attempt in state.history.occurrences.flatMap(\.attempts) { + switch attempt.status { + case .prepared: + guard attempt.epoch == state.epoch else { continue } + try await record(attemptId: attempt.attemptId, outcome: .failed, identity: identity) + case .submitted, .unknown: + guard let outcome = await verifiedOutcome(for: attempt, identity: identity) else { + if attempt.status == .submitted { + try await record(attemptId: attempt.attemptId, outcome: .unknown, identity: identity) + } + continue + } + try await record(attemptId: attempt.attemptId, outcome: outcome, identity: identity) + case .succeeded, .failed: + // Reconciliation can settle an attempt the journal still shows as open. + let outcome: Paykit.PaymentOutcome = attempt.status == .succeeded ? .succeeded : .failed + // The cached stage only skips settled attempts cheaply; a settlement while this loop awaited may have + // settled one since, so the move itself checks the current stage. + if let stage = journalStages[attempt.attemptId], !stage.isTerminal, + let entry = applyJournalOutcome(attemptId: attempt.attemptId, outcome: outcome, identity: identity, onlyIfOpen: true), + outcome == .failed + { + await releaseConsumedPaymentList(of: entry) + } + } + } + } catch { + Logger.warn("Paykit allowance recovery failed: \(error)", context: "PaykitAllowance") + } + } + + /// The SDK's block codes for a request over an amount, count or period limit, as on Android. + static let capacityBlockCodes: Set = ["amount_outside_range", "period_amount_limit", "period_count_limit", "lifetime_amount_limit"] + + static func isCapacityBlock(_ reason: Paykit.AllowanceAccountingBlock) -> Bool { + if case let .sharedRule(code) = reason { + return capacityBlockCodes.contains(code) + } + return false + } + + /// Whether a covered request is over every limit of the contact's accepted allowances, decided without the node. + func exceedsLimits(_ request: PaykitPaymentRequest, allowances: [PaykitAllowance], identity: String) async -> Bool { + let covering = allowances.filter { + $0.isAllower && $0.status(at: now()) == .active && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) + } + guard !covering.isEmpty else { return false } + let attempts = await automaticAttempts() + guard !covering.contains(where: { + PaykitAllowanceCapacity.fits(amountSats: request.allowanceAmountSats, allowance: $0, attempts: attempts, now: now()) + }) else { return false } + notifyLimitReached(request, identity: identity) + return true + } + + private func verifiedOutcome(for attempt: Paykit.PaymentAttemptRecord, identity: String) async -> Paykit.PaymentOutcome? { + switch attempt.status { + case .prepared: + return .failed + case .succeeded, .failed: + return nil + case .submitted, .unknown: + break + } + + guard let entry = localState(identity: identity).journal.first(where: { $0.attemptId == attempt.attemptId }) else { + return nil + } + switch entry.stage { + case .prepared, .submitted: + // The journal is written before the node call, so no payment left this wallet. + return .failed + case .succeeded: + return .succeeded + case .failed: + return .failed + case .sending, .sent, .unknown: + break + } + + if let paymentHash = entry.paymentHash { + switch await lightningLookup.status(paymentHash: paymentHash) { + case .succeeded: + return .succeeded + case .failed: + return .failed + case .pending, .unknown: + return nil + } + } + return entry.transactionId == nil ? nil : .succeeded + } + + private func record(attemptId: String, outcome: Paykit.PaymentOutcome, identity: String) async throws { + try await sdk.recordPaymentOutcome(Paykit.PaymentOutcomeReport(attemptId: attemptId, outcome: outcome)) + let entry = applyJournalOutcome(attemptId: attemptId, outcome: outcome, identity: identity) + if outcome == .failed { + await releaseConsumedPaymentList(of: entry) + } + } + + /// A failed automatic payment leaves the payee's list usable for the manual payment that follows. + private func releaseConsumedPaymentList(of entry: PaykitAllowanceLocalState.JournalEntry?) async { + guard let entry, entry.isAutomatic, let version = entry.paymentListVersion else { return } + await payer.releaseConsumedPaymentList( + publicKey: entry.requestId.counterparty, + version: version, + previousVersion: entry.previousPaymentListVersion + ) + } + + /// Moves the journal entry to the outcome, and announces an automatic payment only from the call that settles it, + /// so a settlement event and recovery never both raise Payment Executed. + @discardableResult + private func applyJournalOutcome( + attemptId: String, + outcome: Paykit.PaymentOutcome, + identity: String, + onlyIfOpen: Bool = false + ) -> PaykitAllowanceLocalState.JournalEntry? { + var entry: PaykitAllowanceLocalState.JournalEntry? + var settledEntry: PaykitAllowanceLocalState.JournalEntry? + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), + !onlyIfOpen || !state.journal[index].stage.isTerminal + else { return } + entry = state.journal[index] + if outcome == .succeeded, state.journal[index].stage != .succeeded { + settledEntry = state.journal[index] + } + switch outcome { + case .succeeded: state.journal[index].stage = .succeeded + case .failed: state.journal[index].stage = .failed + case .unknown: state.journal[index].stage = .unknown + } + } + Self.eventSubject.send(.ledgerChanged) + if let entry = settledEntry, entry.isAutomatic, let paymentHash = entry.paymentHash { + Self.eventSubject.send( + .paidAutomatically(counterparty: entry.requestId.counterparty, amountSats: entry.amountSats, paymentId: paymentHash.lowercased()) + ) + } + return entry + } + + func automaticAttempts() async -> [PaykitAllowanceCapacity.Attempt] { + guard let state = try? await sdk.allowanceAccountingState() else { return [] } + return PaykitAllowanceCapacity.attempts(from: state.history) + } + + func succeededAutomaticPayments(identity: String) -> [PaykitAllowanceLocalState.JournalEntry] { + localState(identity: identity).journal.filter { $0.isAutomatic && $0.stage == .succeeded } + } + + // MARK: Automatic payment + + func autoPay( + _ request: PaykitPaymentRequest, + allowances: [PaykitAllowance], + identity: String + ) async -> PaykitAllowanceAutoPayResult { + guard request.direction == .incoming, + request.billingPeriod == nil, + request.isAllowancePayable, + request.lifecycleState == .proposed, + !inFlightRequestIds.contains(request.id), + allowances.contains(where: { + $0.isAllower && $0.lifecycleState == .accepted && + PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) + }) + else { return .notCovered } + + inFlightRequestIds.insert(request.id) + defer { inFlightRequestIds.remove(request.id) } + + do { + try await ensureReconciled(identity: identity) + // An unstructured task survives a cancelled caller: stopping after the acceptance would leave the request unpaid. + let payment = Task { try await admitAndPay(request, allowances: allowances, identity: identity) } + return try await payment.value + } catch { + Logger.warn("Automatic allowance payment stayed manual: \(error)", context: "PaykitAllowance") + return .manual + } + } + + private func admitAndPay( + _ request: PaykitPaymentRequest, + allowances: [PaykitAllowance], + identity: String + ) async throws -> PaykitAllowanceAutoPayResult { + let scope = Paykit.PaymentRequestScope( + counterparty: request.counterparty, + paymentRequestId: request.paymentRequestId + ) + let selectionTime = trustedTime(identity: identity) + let candidates = try await sdk.evaluateAllowanceCandidates(scope: scope, trustedTime: selectionTime) + guard let candidate = candidates.first(where: { $0.blocked == nil }), + let allowance = allowances.first(where: { + $0.allowanceId == candidate.allowanceId && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) + }) + else { + let reasons = candidates.compactMap { $0.blocked.map { String(describing: $0) } } + Logger.info("No eligible allowance for an incoming request: \(reasons)", context: "PaykitAllowance") + // The SDK blocks a request above the per-payment limit before the capacity check below can notify. + if allowances.contains(where: { + $0.isAllower && $0.lifecycleState == .accepted && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && + $0.perPaymentMaxSats.map { request.allowanceAmountSats > $0 } == true + }) { + notifyLimitReached(request, identity: identity) + } + return .manual + } + + let attempts = await automaticAttempts() + guard PaykitAllowanceCapacity.fits(amountSats: request.allowanceAmountSats, allowance: allowance, attempts: attempts, now: now()) else { + Logger.info("Allowance monthly limit reached; the request stays on the manual flow", context: "PaykitAllowance") + notifyLimitReached(request, identity: identity) + return .manual + } + + let resolvedPayment: PrivatePaykitAllowancePayment? + do { + resolvedPayment = try await payer.resolve(request, eligibleIdentifiers: candidate.eligiblePaymentEndpointIdentifiers) + } catch PaykitAllowanceError.paymentListPending { + Logger.info("Deferred an incoming request until the payee publishes a new payment list", context: "PaykitAllowance") + return .deferred + } + guard let payment = resolvedPayment else { + Logger.info("No payable private endpoint for an allowance payment; leaving it manual", context: "PaykitAllowance") + return .manual + } + // An on-chain miner fee falls outside the allowance's limits, so only Lightning pays automatically. + guard let paymentHash = payment.lightningPaymentHash else { + Logger.info("An on-chain allowance payment stays manual", context: "PaykitAllowance") + return .manual + } + + let endpointIdentifier = payment.endpoint.methodId.rawValue + let paymentAppId = try payment.context.paymentAppId(for: endpointIdentifier) + // The SDK requires this app to hold the shared execution claim before it queues an automatic Acceptance. + try await payer.claimForExecution(request) + let recordedAcceptanceIntent = try await payer.recordAcceptanceIntent(request, identity: identity) + let association: Paykit.AllowanceAssociationRecord + do { + association = try await sdk.acceptPaymentRequestAutomatically( + scope: scope, + selection: Paykit.AllowanceSelectionInput(allowanceId: candidate.allowanceId, expectedRevision: nil, trustedTime: selectionTime), + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: selectionTime) + ) + } catch { + // An interrupted response may follow a committed acceptance, so only a definite refusal drops the intent. + switch error { + case is CancellationError, PaykitError.Transport, PaykitError.Storage, PaykitError.Identity: + break + default: + if recordedAcceptanceIntent { + await payer.discardAcceptanceIntent(request, identity: identity) + } + } + throw error + } + _ = try? await sdk.processOutboundPrivateMessages(counterparty: request.counterparty) + + let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil) + let reservation = try await sdk.reserveAutomaticPayment( + occurrence: occurrence, + expectedAssociationRevision: association.revisions.last?.revision ?? 1, + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + guard case let .ready(prepared) = reservation else { + var isCapacityBlock = false + if case let .blocked(reason) = reservation { + Logger.info("Allowance reservation blocked: \(reason)", context: "PaykitAllowance") + isCapacityBlock = Self.isCapacityBlock(reason) + } + _ = try? await sdk.markPaymentManualOnly(occurrence: occurrence) + // Only a limit says Limit Reached; an ended allowance or a manual-only decision just asks. + if isCapacityBlock { + notifyLimitReached(request, identity: identity) + } + return .manual + } + + journal( + PaykitAllowanceLocalState.JournalEntry( + attemptId: prepared.attemptId, + isAutomatic: true, + requestId: request.id, + allowanceId: prepared.allowanceId, + amountSats: request.allowanceAmountSats, + paymentEndpointIdentifier: endpointIdentifier, + paymentHash: payment.lightningPaymentHash, + onchainAddress: nil, + transactionId: nil, + stage: .prepared, + createdAt: now() + ), + identity: identity + ) + + // Begin fetches nothing, so pull the link first: an End or a cancellation must be seen before the handoff. + _ = try? await sdk.receivePrivateMessages(counterparty: request.counterparty) + let handoff: Paykit.PaymentAttemptDecision + do { + handoff = try await sdk.beginPaymentExecution( + attemptId: prepared.attemptId, + checks: checks(request, endpointIdentifier: endpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + } catch { + // Nothing left the wallet, so release the reservation before the request goes back to manual payment. + try? await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity) + throw error + } + guard case let .ready(submitted) = handoff else { + try await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity) + return .manual + } + setStage(.submitted, attemptId: submitted.attemptId, identity: identity) + + let listAttemptId = UUID() + do { + try await payer.consumePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: listAttemptId) + if let consumedVersion = payment.context.paymentListVersion { + let previousVersion = await payer.previousPaymentListVersion(publicKey: request.counterparty, attemptId: listAttemptId) + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == submitted.attemptId }) else { return } + state.journal[index].paymentListVersion = consumedVersion + state.journal[index].previousPaymentListVersion = previousVersion + } + } + try await payer.ensurePaymentAllowed(request) + try await payer.prepareProof( + request, + paymentAppId: paymentAppId, + paymentEndpointIdentifier: endpointIdentifier, + allowanceId: submitted.allowanceId + ) + } catch { + await payer.cancelProofPreparation(request) + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: submitted.attemptId, identity: identity, + outcome: .definitePreBroadcastFailure + ) + try await record(attemptId: submitted.attemptId, outcome: .failed, identity: identity) + throw error + } + + return try await payLightning( + request, + payment: payment, + paymentHash: paymentHash, + attemptId: submitted.attemptId, + listAttemptId: listAttemptId, + identity: identity + ) + } + + private func releasePaymentList( + _ request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + attemptId: UUID, + journalAttemptId: String, + identity: String, + outcome: PrivatePaymentListSendOutcome + ) async { + if outcome == .definitePreBroadcastFailure { + // Released here, so recording the attempt failed must not release it again: by then another payment may + // have consumed the same list version. + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == journalAttemptId }) else { return } + state.journal[index].paymentListVersion = nil + state.journal[index].previousPaymentListVersion = nil + } + } + await payer.resolvePaymentList(publicKey: request.counterparty, context: payment.context, attemptId: attemptId, outcome: outcome) + } + + private func payLightning( + _ request: PaykitPaymentRequest, + payment: PrivatePaykitAllowancePayment, + paymentHash: String, + attemptId: String, + listAttemptId: UUID, + identity: String + ) async throws -> PaykitAllowanceAutoPayResult { + do { + try setStageDurably(.sending, attemptId: attemptId, identity: identity) + try await payer.associateLightningPayment(request, paymentHash: paymentHash) + } catch { + await payer.cancelProofPreparation(request) + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, + outcome: .definitePreBroadcastFailure + ) + try await record(attemptId: attemptId, outcome: .failed, identity: identity) + throw error + } + + do { + try await payer.payLightning( + request, + bolt11: payment.endpoint.value, + sats: payment.lightningInvoiceHasAmount ? nil : request.allowanceAmountSats + ) + } catch { + guard await payer.failLightningPayment(paymentHash: paymentHash, submissionError: error) else { + // The node may have started the payment; its settlement event records the outcome. + Logger.warn("Automatic Lightning allowance payment outcome is uncertain: \(error)", context: "PaykitAllowance") + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain + ) + if !hasTerminalStage(attemptId: attemptId, identity: identity) { + try await record(attemptId: attemptId, outcome: .unknown, identity: identity) + } + return .started + } + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, + outcome: .definitePreBroadcastFailure + ) + try await record(attemptId: attemptId, outcome: .failed, identity: identity) + throw error + } + await releasePaymentList( + request, payment: payment, attemptId: listAttemptId, journalAttemptId: attemptId, identity: identity, outcome: .uncertain + ) + setStage(.sent, attemptId: attemptId, identity: identity) + Logger.info("Handed an allowance payment to the node", context: "PaykitAllowance") + return .started + } + + /// Called from the node's payment events for every outbound Lightning payment; only journaled ones are Allowance work. + func lightningPaymentSettled(paymentHash: String, succeeded: Bool) async { + guard let identity = activeIdentity else { return } + let entries = localState(identity: identity).journal.filter { + $0.paymentHash?.caseInsensitiveCompare(paymentHash) == .orderedSame && + [.sending, .sent, .unknown, .submitted].contains($0.stage) + } + for entry in entries { + do { + try await record(attemptId: entry.attemptId, outcome: succeeded ? .succeeded : .failed, identity: identity) + } catch { + Logger.warn("Failed to record an allowance payment outcome: \(error)", context: "PaykitAllowance") + } + } + } + + // MARK: Manual payments + + /// Reports a user-approved payment of an incoming request to the shared ledger before it leaves the wallet. + /// Throws `alreadyRecorded` when another live attempt exists for the request, so the same request is never paid twice. + func beginManualPayment(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String) async throws -> String? { + // The ledger counts bitcoin only, so a payment made over another rail stays out of it. + guard let identity = activeIdentity, request.billingPeriod == nil, request.direction == .incoming, + request.isAllowancePayable, PublicPaykitService.MethodId(rawValue: paymentEndpointIdentifier).map({ $0 != .usdtArbitrum }) == true + else { return nil } + do { + try await ensureReconciled(identity: identity) + let scope = Paykit.PaymentRequestScope( + counterparty: request.counterparty, + paymentRequestId: request.paymentRequestId + ) + let occurrence = Paykit.PaymentOccurrence(request: scope, billingPeriod: nil) + let decision = try await sdk.reserveManualPayment( + occurrence: occurrence, + checks: checks(request, endpointIdentifier: paymentEndpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + guard case let .ready(prepared) = decision else { + if case .blocked(.paymentAlreadyRecorded) = decision { + throw PaykitAllowanceManualPaymentError.alreadyRecorded + } + Logger.info("Manual payment not reported to the allowance ledger: \(decision)", context: "PaykitAllowance") + return nil + } + journal( + PaykitAllowanceLocalState.JournalEntry( + attemptId: prepared.attemptId, + isAutomatic: false, + requestId: request.id, + allowanceId: nil, + amountSats: request.allowanceAmountSats, + paymentEndpointIdentifier: paymentEndpointIdentifier, + paymentHash: nil, + onchainAddress: nil, + transactionId: nil, + stage: .prepared, + createdAt: now() + ), + identity: identity + ) + let handoff = try await sdk.beginPaymentExecution( + attemptId: prepared.attemptId, + checks: checks(request, endpointIdentifier: paymentEndpointIdentifier, trustedTime: trustedTime(identity: identity)) + ) + guard case .ready = handoff else { + try await record(attemptId: prepared.attemptId, outcome: .failed, identity: identity) + return nil + } + setStage(.submitted, attemptId: prepared.attemptId, identity: identity) + Logger.info("Reported a manual payment to the allowance ledger", context: "PaykitAllowance") + return prepared.attemptId + } catch let error as PaykitAllowanceManualPaymentError { + throw error + } catch { + Logger.warn("Manual payment not reported to the allowance ledger: \(error)", context: "PaykitAllowance") + return nil + } + } + + func manualLightningPaymentSent(attemptId: String, paymentHash: String) { + guard let identity = activeIdentity else { return } + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + state.journal[index].paymentHash = paymentHash.lowercased() + state.journal[index].stage = .sent + } + } + + /// Called right before a manual on-chain broadcast, so recovery after a kill reads the attempt as unknown, not failed. + func manualOnchainPaymentSending(attemptId: String) { + guard let identity = activeIdentity else { return } + setStage(.sending, attemptId: attemptId, identity: identity) + } + + func finishManualPayment(attemptId: String, outcome: Paykit.PaymentOutcome, transactionId: String? = nil) async { + guard let identity = activeIdentity else { return } + if let transactionId { + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { return } + state.journal[index].transactionId = transactionId + } + } + do { + try await record(attemptId: attemptId, outcome: outcome, identity: identity) + } catch { + Logger.warn("Failed to record a manual payment outcome: \(error)", context: "PaykitAllowance") + } + } + + // MARK: Helpers + + private func checks(_ request: PaykitPaymentRequest, endpointIdentifier: String, trustedTime: String) throws -> Paykit.PaymentExecutionChecks { + // The ledger counts bitcoin only; a USD or USDT amount must never reach it as sats. + guard request.isAllowancePayable else { throw PaykitAllowanceError.unavailable } + return try Paykit.PaymentExecutionChecks( + trustedTime: trustedTime, + paymentEndpointIdentifier: endpointIdentifier, + actualAmount: Paykit.AccountingAmount(value: request.amountValue, asset: PaykitIssuerInterop.bitcoinAsset), + endpointCurrent: true, + localEnabled: true, + recurrenceEligible: true + ) + } + + private func journal(_ entry: PaykitAllowanceLocalState.JournalEntry, identity: String) { + updateLocalState(identity: identity) { state in + state.journal.removeAll { $0.attemptId == entry.attemptId } + state.journal.append(entry) + if state.journal.count > 200 { + state.journal.removeFirst(state.journal.count - 200) + } + } + } + + /// Recovery reads an entry still at `.submitted` as never sent, so the send must not start before `.sending` is saved. + private func setStageDurably(_ stage: PaykitAllowanceLocalState.Stage, attemptId: String, identity: String) throws { + var state = try store.load(identity: identity) + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }) else { + throw PaykitAllowanceError.unavailable + } + state.journal[index].stage = stage + try store.save(state, identity: identity) + } + + /// Settlement can record an outcome while a send is still cleaning up, so a later stage never replaces it. + private func setStage(_ stage: PaykitAllowanceLocalState.Stage, attemptId: String, identity: String) { + updateLocalState(identity: identity) { state in + guard let index = state.journal.firstIndex(where: { $0.attemptId == attemptId }), + !state.journal[index].stage.isTerminal + else { return } + state.journal[index].stage = stage + } + } + + private func hasTerminalStage(attemptId: String, identity: String) -> Bool { + localState(identity: identity).journal.first { $0.attemptId == attemptId }?.stage.isTerminal == true + } + + private func notifyLimitReached(_ request: PaykitPaymentRequest, identity: String) { + var isNew = false + updateLocalState(identity: identity) { state in + isNew = state.notifiedRequestIds.insert(request.paymentRequestId).inserted + } + if isNew { + Self.eventSubject.send(.limitReached(counterparty: request.counterparty, amountSats: request.allowanceAmountSats)) + } + } +} diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift new file mode 100644 index 000000000..e920538d8 --- /dev/null +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -0,0 +1,385 @@ +import Foundation +import Observation +import Paykit +import UserNotifications + +/// One grant as the user sees it. An SDK Allowance binds this wallet's identity to the contact's identity and covers +/// every Paykit app they use, so a grant is one SDK Allowance. The row keeps the USD limits picked when it was made. +struct PaykitAllowanceEntry: Identifiable, Hashable { + let id: String + let allowances: [PaykitAllowance] + let limits: PaykitAllowanceLimits? + + var primary: PaykitAllowance { + allowances[0] + } + + var counterparty: String { + primary.counterparty + } + + var role: PaykitAllowance.Role { + primary.role + } + + var perPaymentMaxSats: UInt64? { + primary.perPaymentMaxSats + } + + var monthlyLimitSats: UInt64? { + primary.monthlyLimitSats + } + + var canEnd: Bool { + allowances.contains(where: \.canEnd) + } + + func status(at now: Date) -> PaykitAllowance.Status { + primary.status(at: now) + } +} + +enum PaykitAllowanceError: LocalizedError { + case contactNotLinked + case unavailable + case paymentListPending + + var errorDescription: String? { + switch self { + case .contactNotLinked: t("subscriptions__allowance_error_not_linked") + case .unavailable, .paymentListPending: t("subscriptions__allowance_error_unavailable") + } + } +} + +@Observable +@MainActor +final class PaykitAllowanceManager { + private(set) var allowances: [PaykitAllowance] = [] + private(set) var localState = PaykitAllowanceLocalState() + private(set) var isWorking = false + private(set) var autoPaidRequestIds: Set = [] + /// Keyed by `PaykitAllowance.scopedId`, since another contact can reuse an allowance id. + private(set) var autoPaidSatsByScopedId: [String: UInt64] = [:] + + @ObservationIgnored private let sdk: any PaykitAllowanceSdkHandling + @ObservationIgnored private let executor: PaykitAllowanceExecutor + @ObservationIgnored private let now: () -> Date + @ObservationIgnored private let canPayNow: @MainActor () -> Bool + @ObservationIgnored private var identity: String? + @ObservationIgnored private var isProcessingRequests = false + private var updateDepth = 0 + + /// Whether allowances are being refreshed or a pass is deciding requests, so a covered request may still change hands. + var isUpdating: Bool { + updateDepth > 0 + } + + @ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:] + + init( + sdk: any PaykitAllowanceSdkHandling = PaykitSdkService.shared, + executor: PaykitAllowanceExecutor = .shared, + now: @escaping () -> Date = { Date() }, + canPayNow: @escaping @MainActor () -> Bool = PaykitAllowanceManager.lightningReady + ) { + self.sdk = sdk + self.executor = executor + self.now = now + self.canPayNow = canPayNow + } + + /// A node that just started lists its channels before they reconnect, and a payment sent then finds no route. + static func lightningReady() -> Bool { + guard LightningService.shared.status?.isRunning == true, + let channels = LightningService.shared.channels + else { return false } + return channels.isEmpty || channels.contains(where: \.isUsable) + } + + var entries: [PaykitAllowanceEntry] { + var grouped: [String: [PaykitAllowance]] = [:] + var order: [String] = [] + for allowance in allowances { + let key = localState.group(containing: allowance.allowanceId, counterparty: allowance.counterparty)?.id + ?? allowance.scopedId + if grouped[key] == nil { + order.append(key) + } + grouped[key, default: []].append(allowance) + } + return order.compactMap { key in + guard let allowances = grouped[key], !allowances.isEmpty else { return nil } + let limits = localState.groups.first { $0.id == key }?.limits + return PaykitAllowanceEntry(id: key, allowances: allowances, limits: limits) + } + } + + func entry(id: String) -> PaykitAllowanceEntry? { + entries.first { $0.id == id } + } + + func activate(identity: String?) async { + guard let identity else { + deactivate() + return + } + let identityChanged = self.identity != identity + self.identity = identity + await executor.activate(identity: identity) + if identityChanged { + manualRequestIds = [:] + await executor.recover(identity: identity) + } + await refresh() + } + + func deactivate() { + identity = nil + Task { await executor.activate(identity: nil) } + allowances = [] + localState = PaykitAllowanceLocalState() + autoPaidRequestIds = [] + autoPaidSatsByScopedId = [:] + manualRequestIds = [:] + } + + func refresh() async { + guard let identity else { return } + updateDepth += 1 + defer { updateDepth -= 1 } + do { + let records = try await sdk.listAllowances( + filter: Paykit.AllowanceFilter(counterparty: nil, localRole: nil, states: []) + ) + allowances = records + .filter { $0.historyStatus == .consistent || $0.historyStatus == .unresolvedReferences } + .compactMap(PaykitAllowance.init(record:)) + } catch { + Logger.warn("Failed to list Paykit allowances: \(error)", context: "PaykitAllowance") + } + localState = await executor.localState(identity: identity) + let paid = await executor.succeededAutomaticPayments(identity: identity) + autoPaidRequestIds = Set(paid.map(\.requestId)) + autoPaidSatsByScopedId = paid.reduce(into: [:]) { totals, entry in + guard let allowanceId = entry.allowanceId else { return } + totals[PaykitAllowance.scopedId(counterparty: entry.requestId.counterparty, allowanceId: allowanceId), default: 0] += entry.amountSats + } + } + + func autoPaidSats(for entry: PaykitAllowanceEntry) -> UInt64 { + entry.allowances.reduce(0) { $0 + (autoPaidSatsByScopedId[$1.scopedId] ?? 0) } + } + + /// Whether an incoming request is covered by an active Allowance this wallet granted. A request created before the + /// Allowance was accepted stays manual: it may already have been shown to the user for a decision. + func coversRequest(_ request: PaykitPaymentRequest) -> Bool { + guard request.isAllowancePayable else { return false } + return allowances.contains { allowance in + guard allowance.isAllower, allowance.status(at: now()) == .active, + PubkyPublicKeyFormat.matches(allowance.counterparty, request.counterparty), let acceptedAt = allowance.lastEventAt, + let createdAt = request.createdAt else { return false } + return createdAt >= acceptedAt.addingTimeInterval(-Self.acceptanceClockTolerance) + } + } + + private var allowancesSignature: Int { + var hasher = Hasher() + for allowance in allowances { + hasher.combine(allowance.allowanceId) + hasher.combine(allowance.lifecycleState.rawDescription) + } + hasher.combine(autoPaidSatsByScopedId.values.reduce(0, +)) + return hasher.finalize() + } + + // MARK: Lifecycle + + func propose(to contact: PubkyContact, limits: PaykitAllowanceLimits) async throws { + guard let identity else { throw PaykitAllowanceError.unavailable } + isWorking = true + defer { isWorking = false } + + let isLinked = try await sdk.linkedPeers().contains { + PubkyPublicKeyFormat.matches($0.counterparty, contact.publicKey) && $0.state == .linked + } + guard isLinked else { throw PaykitAllowanceError.contactNotLinked } + + let terms = try limits.terms( + monthAnchor: PaykitAllowanceTime.monthStart(containing: now()), + allowedPaymentEndpointIdentifiers: Self.allowedPaymentEndpointIdentifiers + ) + let record = try await sdk.proposeAllowance(counterparty: contact.publicKey, localRole: .allower, terms: terms) + _ = try? await sdk.processOutboundPrivateMessages(counterparty: contact.publicKey) + + let group = PaykitAllowanceLocalState.Group( + id: UUID().uuidString.lowercased(), + counterparty: contact.publicKey, + limits: limits, + allowanceIds: [record.allowanceId], + createdAt: now() + ) + await executor.updateLocalState(identity: identity) { $0.groups.append(group) } + Logger.info("Proposed an allowance", context: "PaykitAllowance") + await refresh() + } + + func accept(_ entry: PaykitAllowanceEntry) async throws { + try await respond(to: entry) { allowance in + guard let proposalStreamItemId = allowance.proposalStreamItemId else { throw PaykitAllowanceError.unavailable } + return try await self.sdk.acceptAllowance( + counterparty: allowance.counterparty, + allowanceId: allowance.allowanceId, + expectedProposalStreamItemId: proposalStreamItemId + ) + } + } + + func reject(_ entry: PaykitAllowanceEntry) async throws { + try await respond(to: entry) { allowance in + try await self.sdk.rejectAllowance(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId) + } + } + + func end(_ entry: PaykitAllowanceEntry) async throws { + isWorking = true + defer { isWorking = false } + var endedAny = false + for allowance in entry.allowances where allowance.canEnd { + _ = try await sdk.endAllowance(counterparty: allowance.counterparty, allowanceId: allowance.allowanceId) + endedAny = true + _ = try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty) + } + guard endedAny else { throw PaykitAllowanceError.unavailable } + await refresh() + } + + private func respond(to entry: PaykitAllowanceEntry, _ response: (PaykitAllowance) async throws -> Paykit.AllowanceRecord) async throws { + isWorking = true + defer { isWorking = false } + var respondedAny = false + for allowance in entry.allowances where allowance.isAnswerable { + _ = try await response(allowance) + respondedAny = true + _ = try? await sdk.processOutboundPrivateMessages(counterparty: allowance.counterparty) + } + guard respondedAny else { throw PaykitAllowanceError.unavailable } + if let identity { + let ids = entry.allowances.map(\.scopedId) + await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) } + } + await refresh() + } + + // MARK: Presentation + + func proposalForPresentation() -> PaykitAllowanceEntry? { + entries.first { entry in + entry.allowances.contains(where: \.isAnswerable) && + !entry.allowances.contains { localState.presentedProposalIds.contains($0.scopedId) } + } + } + + func markProposalPresented(_ entry: PaykitAllowanceEntry) async { + guard let identity else { return } + let ids = entry.allowances.map(\.scopedId) + await executor.updateLocalState(identity: identity) { $0.presentedProposalIds.formUnion(ids) } + localState.presentedProposalIds.formUnion(ids) + } + + // MARK: Automatic payments + + /// Pays incoming requests that an active Allowance covers. Returns whether any request was handled or handed back + /// for manual payment, so the caller refreshes before presenting the rest. + func processIncomingRequests(_ requests: [PaykitPaymentRequest]) async -> Bool { + guard let identity, !isProcessingRequests else { return false } + updateDepth += 1 + defer { updateDepth -= 1 } + let signature = allowancesSignature + let covered = requests.filter(isAwaitingAutomaticPayment) + guard !covered.isEmpty else { return false } + guard canPayNow() else { + // A request over the limits needs the user anyway, so it is handed back before waiting for a channel. + var handedBack = false + for request in covered where await executor.exceedsLimits(request, allowances: allowances, identity: identity) { + manualRequestIds[request.id] = signature + handedBack = true + } + Logger.info("Holding covered requests until the node has a usable channel", context: "PaykitAllowance") + return handedBack + } + + isProcessingRequests = true + defer { isProcessingRequests = false } + var handledAny = false + for request in covered { + let result = await executor.autoPay(request, allowances: allowances, identity: identity) + Logger.info("Automatic payment pass for a covered request ended as \(result)", context: "PaykitAllowance") + switch result { + case .started: + handledAny = true + case .manual, .notCovered: + manualRequestIds[request.id] = signature + case .deferred: + break + } + } + if handledAny { + await refresh() + } + return handledAny + } + + /// Whether the allowance flow owns the request, so no sheet may open for it. A covered request belongs to the flow + /// from the moment it arrives, even before the first pass over it, until that pass finds it manual. + func isAutomaticallyHandling(_ request: PaykitPaymentRequest) async -> Bool { + if isAwaitingAutomaticPayment(request) { + return true + } + return await executor.isHandling(request.id) + } + + /// Whether an allowance this wallet grants, or was asked to grant, could start covering the request's contact. + func mayCover(_ request: PaykitPaymentRequest) -> Bool { + allowances.contains { + $0.isAllower && PubkyPublicKeyFormat.matches($0.counterparty, request.counterparty) && + ($0.lifecycleState == .proposed || $0.status(at: now()) == .active) + } + } + + private func isAwaitingAutomaticPayment(_ request: PaykitPaymentRequest) -> Bool { + request.requiresAcceptance && request.billingPeriod == nil && coversRequest(request) && manualRequestIds[request.id] != allowancesSignature + } + + static let acceptanceClockTolerance: TimeInterval = 30 + + static let allowedPaymentEndpointIdentifiers: [String] = PaykitIssuerInterop.supportedEndpointIdentifiers( + PublicPaykitService.MethodId.publishableMethodIds.filter { $0 != .usdtArbitrum }.map(\.rawValue), + network: Env.network + ) +} + +/// Allowance outcomes reach the user as a notification banner when notifications are allowed, or as a toast. +enum PaykitAllowanceNotifier { + @MainActor + static func post(title: String, body: String, fallback: @escaping @MainActor () -> Void) { + Task { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { + fallback() + return + } + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = ["bitkit_action": "paykit_allowance"] + do { + try await center.add(UNNotificationRequest(identifier: "paykit-allowance-\(UUID().uuidString)", content: content, trigger: nil)) + } catch { + fallback() + } + } + } +} diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index ec8410d2b..2323ef787 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -51,6 +51,8 @@ struct PendingPaykitPaymentProof: Codable, Equatable { let paymentEndpointIdentifier: String let kind: PaykitPaymentProofKind let billingPeriod: PaykitBillingPeriod? + /// Set only for an automatic Allowance payment, from its succeeded attempt. Manual payments omit it. + var allowanceId: String? var paymentStarted: Bool var paymentIdentifier: String? var proofData: String? @@ -102,6 +104,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, billingPeriod: PaykitBillingPeriod? = nil, + allowanceId: String? = nil, paymentStarted: Bool = false, paymentIdentifier: String?, proofData: String?, @@ -119,6 +122,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable { self.paymentEndpointIdentifier = paymentEndpointIdentifier self.kind = kind self.billingPeriod = billingPeriod + self.allowanceId = allowanceId self.paymentStarted = paymentStarted self.paymentIdentifier = paymentIdentifier self.proofData = proofData @@ -345,7 +349,8 @@ actor PaykitPaymentProofService { paymentAppId: String, paymentEndpointIdentifier: String, kind: PaykitPaymentProofKind, - paymentTerms: PaykitRequestPricing.Payment? = nil + paymentTerms: PaykitRequestPricing.Payment? = nil, + allowanceId: String? = nil ) async throws { if try await attemptService.hasAttempt(for: request.id) { if kind == .onchain, @@ -372,6 +377,7 @@ actor PaykitPaymentProofService { if kind == .onchain { proof.onchainAmountSats = payment.amount.atomic } + proof.allowanceId = allowanceId try await mutationLock.withLock { try await prepareLocked(request: request, proof: proof) } } @@ -1638,7 +1644,7 @@ actor PaykitPaymentProofService { billingPeriod: pendingProof.billingPeriod?.sdkValue, paymentAppId: pendingProof.paymentAppId, paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, - allowanceId: nil, + allowanceId: pendingProof.allowanceId, conversionQuoteId: pendingProof.conversionQuoteId, proof: Paykit.PrivateJsonObject(text: proofText) ) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 41edccae1..3c4451b98 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1897,6 +1897,17 @@ final class PaykitPaymentRequestManager { } } + /// Re-reads the locally accepted request ids after another component saved one, such as the allowance executor. + /// Returns whether the set changed, so the caller refreshes before presenting. + @discardableResult + func reloadAcceptedRequestIds() -> Bool { + guard let identity = activeIdentity, let ids = try? acceptanceStore.load(identity: identity), ids != acceptedRequestIds else { + return false + } + acceptedRequestIds = ids + return true + } + func ensureOnchainRecoveryAllowed( _ attempt: OnchainSendAttempt, proofService: PaykitPaymentProofService ) async throws -> (request: PaykitPaymentRequest, paymentDeadline: PaykitPreciseInstant?) { @@ -2183,6 +2194,15 @@ final class PaykitPaymentRequestManager { return true } + /// Ends a requested presentation the allowance flow owns, so it does not hold every other Paykit sheet back. + func releaseRequestedPresentation(_ request: PaykitPaymentRequest) { + guard requestedPresentationId == request.id else { return } + requestedPresentationId = nil + presentationRetryAttempts.removeValue(forKey: request.id) + presentationRetryDeadlines.removeValue(forKey: request.id) + presentationGeneration += 1 + } + func dismissPreparingRequest(_ request: PaykitPaymentRequest) { guard pendingRequests.contains(where: { $0.id == request.id }), requestedPresentationId == nil || requestedPresentationId == request.id, diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index 129532b18..6de3653f8 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -1,3 +1,4 @@ +import BitkitCore import Foundation import Paykit @@ -345,6 +346,28 @@ extension PrivatePaykitService { ) } + /// Rolls back a list version a failed automatic payment consumed, which a restart may have left without its + /// in-memory consumption record. A newer consumption is left alone. + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) throws { + guard let publicKey = PubkyPublicKeyFormat.normalized(publicKey) else { + throw PrivatePaykitError.invalidPublicKey + } + guard var contactState = state.contacts[publicKey], contactState.consumedPrivatePaymentListVersion == version else { return } + let previousContactState = state.contacts[publicKey] + contactState.consumedPrivatePaymentListVersion = previousVersion + state.contacts[publicKey] = contactState + do { + try persistStateOrThrow(markWalletBackup: true) + } catch { + state.contacts[publicKey] = previousContactState + throw error + } + Logger.info( + "Released private Paykit payment list version \(version) for \(PubkyPublicKeyFormat.redacted(publicKey)) after a failed automatic payment", + context: "PrivatePaykit" + ) + } + func consumedPaymentListVersion(publicKey: String, attemptId: UUID? = nil) -> UInt64? { guard let key = PubkyPublicKeyFormat.normalized(publicKey) else { return nil } if let attemptId, let consumption = privatePaymentListConsumptions[PrivatePaymentListConsumptionKey(attemptId: attemptId, publicKey: key)] { @@ -514,6 +537,66 @@ extension PrivatePaykitService { persistState(markWalletBackup: true) } + /// Resolves the payee's current private endpoint for an automatic Allowance payment. Only endpoints the Allowance and + /// the request both accept qualify, and only ones this wallet can pay without asking: a bolt11 invoice for exactly + /// the requested amount (or amount-less), or an unused on-chain address. Public endpoints are never used. + func resolveAllowancePayment( + _ request: PaykitPaymentRequest, + eligibleIdentifiers: [String] + ) async throws -> PrivatePaykitAllowancePayment? { + guard !request.isExpired(at: Date()), + let publicKey = PubkyPublicKeyFormat.normalized(request.counterparty) + else { return nil } + + let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersion + // Resolve through the request, as the manual flow does, so its required app and bound destination apply. + let prepared = try await PaykitSdkService.shared.prepareAndResolvePrivatePaymentRequest( + counterparty: publicKey, + paymentRequestId: request.paymentRequestId, + afterPrivatePaymentListVersion: consumedVersion + ) + if prepared.resolution.state == .recoveryPending || prepared.resolution.status == .waitingForUpdatedPaymentList { + // The last list was already paid from; a new one arrives once the payee sees that payment settle. + schedulePrivatePaymentRecovery(for: publicKey) + throw PaykitAllowanceError.paymentListPending + } + // Without a payment list version nothing limits how often the payee's endpoints can be paid, so the request asks. + guard let paymentListVersion = prepared.resolution.privatePaymentListVersion else { return nil } + + let eligible = Set(eligibleIdentifiers).intersection(request.acceptedPaymentEndpointIdentifiers) + let candidates = resolvedEndpoints(from: prepared.resolution).filter { + eligible.contains($0.methodId.rawValue) && + ($0.methodId == .bitcoinLightningBolt11 || $0.methodId.onchainNetwork != nil) && $0.appId != nil + } + let payable = await privatePayableEndpoints(from: candidates, publicKey: publicKey) + + for methodId in PublicPaykitService.MethodId.payablePreferenceOrder { + guard let endpoint = payable.first(where: { $0.methodId == methodId }), let appId = endpoint.appId else { continue } + let context = PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [methodId.rawValue: appId], + paymentListVersion: paymentListVersion + ) + if methodId == .bitcoinLightningBolt11 { + guard case let .lightning(invoice) = try? await decode(invoice: endpoint.value), + invoice.amountSatoshis == 0 || invoice.amountSatoshis == request.allowanceAmountSats + else { continue } + return PrivatePaykitAllowancePayment( + endpoint: endpoint, + context: context, + lightningPaymentHash: invoice.paymentHash.hex, + lightningInvoiceHasAmount: invoice.amountSatoshis != 0 + ) + } + return PrivatePaykitAllowancePayment( + endpoint: endpoint, + context: context, + lightningPaymentHash: nil, + lightningInvoiceHasAmount: false + ) + } + return nil + } + private func resolvedEndpoints(from resolution: PrivateContactPaymentResolution) -> [PublicPaykitService.Endpoint] { resolution.payableEndpoints.compactMap { var endpoint = PublicPaykitService.parseEndpoint(identifier: $0.identifier, payload: $0.target.payload) diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift index b83a45334..943364481 100644 --- a/Bitkit/Services/PubkyService.swift +++ b/Bitkit/Services/PubkyService.swift @@ -1211,6 +1211,125 @@ actor PaykitSdkService { } } + func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] { + try await operationLock.withLock { + try await handle().listAllowances(filter: filter) + } + } + + func proposeAllowance( + counterparty: String, + localRole: Paykit.AllowanceLocalRole, + terms: Paykit.AllowanceTerms + ) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.proposeAllowance( + counterparty: counterparty, + localRole: localRole, + terms: terms + ) + } + } + + func acceptAllowance( + counterparty: String, + allowanceId: String, + expectedProposalStreamItemId: UInt64 + ) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking(priority: .interactive) { sdk in + try await sdk.acceptAllowance( + counterparty: counterparty, + allowanceId: allowanceId, + expectedProposalStreamItemId: expectedProposalStreamItemId + ) + } + } + + func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.rejectAllowance(counterparty: counterparty, allowanceId: allowanceId) + } + } + + func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.endAllowance(counterparty: counterparty, allowanceId: allowanceId) + } + } + + func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? { + try await operationLock.withLock { + try await handle().allowanceAccountingState() + } + } + + func reconcileAllowanceAccounting( + _ reconciliation: Paykit.AllowanceAccountingReconciliation + ) async throws -> Paykit.AllowanceAccountingState { + try await withStateRevisionTracking { sdk in + try await sdk.reconcileAllowanceAccounting(reconciliation: reconciliation) + } + } + + func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] { + try await withStateRevisionTracking { sdk in + try await sdk.evaluateAllowanceCandidates(scope: scope, trustedTime: trustedTime) + } + } + + func acceptPaymentRequestAutomatically( + scope: Paykit.PaymentRequestScope, + selection: Paykit.AllowanceSelectionInput, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.AllowanceAssociationRecord { + try await withStateRevisionTracking { sdk in + try await sdk.acceptPaymentRequestAutomatically(scope: scope, selection: selection, checks: checks) + } + } + + func reserveAutomaticPayment( + occurrence: Paykit.PaymentOccurrence, + expectedAssociationRevision: UInt64, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision { + try await withStateRevisionTracking { sdk in + try await sdk.reserveAutomaticPayment( + occurrence: occurrence, + expectedAssociationRevision: expectedAssociationRevision, + checks: checks + ) + } + } + + func reserveManualPayment( + occurrence: Paykit.PaymentOccurrence, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision { + try await withStateRevisionTracking { sdk in + try await sdk.reserveManualPayment(occurrence: occurrence, checks: checks) + } + } + + func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision { + try await withStateRevisionTracking { sdk in + try await sdk.beginPaymentExecution(attemptId: attemptId, checks: checks) + } + } + + @discardableResult + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord { + try await withStateRevisionTracking { sdk in + try await sdk.recordPaymentOutcome(report: report) + } + } + + @discardableResult + func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord { + try await withStateRevisionTracking { sdk in + try await sdk.markPaymentManualOnly(occurrence: occurrence) + } + } + func linkedPeers() async throws -> [LinkedPeerRecord] { try await linkedPeers(priority: .ordered) } diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index af882b0ba..030f791e9 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -36,6 +36,13 @@ struct PrivatePaykitPaymentContext: Equatable { } } +struct PrivatePaykitAllowancePayment: Equatable { + let endpoint: PublicPaykitService.Endpoint + let context: PrivatePaykitPaymentContext + let lightningPaymentHash: String? + let lightningInvoiceHasAmount: Bool +} + enum IncomingPaykitPaymentRequestFailureReason: String, Hashable { case noSupportedEndpoint = "no_supported_endpoint" case endpointNotPayable = "endpoint_not_payable" diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift index 157cee373..f2da3def5 100644 --- a/Bitkit/Utilities/Keychain.swift +++ b/Bitkit/Utilities/Keychain.swift @@ -16,6 +16,7 @@ enum KeychainEntryType { case paykitKeyGeneration(publicKey: String) case paykitRecoveryBackup case paykitPendingBackupRestore + case paykitAllowanceState case pubkySecretKey var storageKey: String { @@ -34,6 +35,7 @@ enum KeychainEntryType { case let .paykitKeyGeneration(publicKey): "paykit_key_generation_\(publicKey)" case .paykitRecoveryBackup: "paykit_recovery_backup" case .paykitPendingBackupRestore: "paykit_pending_backup_restore" + case .paykitAllowanceState: "paykit_allowance_state" case .pubkySecretKey: "pubky_secret_key" } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 203fb7df9..36f14680c 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1775,6 +1775,7 @@ extension AppViewModel { paymentHash: paymentHash, preimage: paymentPreimage ) + await PaykitAllowanceExecutor.shared.lightningPaymentSettled(paymentHash: paymentHash, succeeded: true) } let outcome = QuickPayPaymentCoordinator.shared.complete( paymentId: paymentId, @@ -1808,7 +1809,10 @@ extension AppViewModel { ) let hash = paymentHash ?? outcome.invoicePaymentHash ?? paymentId if let paymentHash = paymentHash ?? paymentId { - Task { await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) } + Task { + await PaykitPaymentProofService.shared.failLightningPayment(paymentHash: paymentHash) + await PaykitAllowanceExecutor.shared.lightningPaymentSettled(paymentHash: paymentHash, succeeded: false) + } } let awaitingSheet = hash.map { pendingPaymentHashes.contains($0) } ?? false if let hash, awaitingSheet { diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index 6648e5a1b..8bb1193b1 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -266,6 +266,7 @@ struct PaymentRequestsSheet: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var sheets: SheetViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + @Environment(PaykitAllowanceManager.self) private var allowances let config: PaymentRequestsSheetItem @@ -319,8 +320,15 @@ struct PaymentRequestsSheet: View { navigation.navigate(.paymentRequestDetail(request.id)) }, onPay: { - sheets.hideSheetBeforePerforming(reason: "Paying payment request") { - _ = paymentRequests.requestPresentation(request) + Task { + // Closing the sheet first would leave nothing on screen behind the toast. + if await allowances.isAutomaticallyHandling(request) { + app.toast(type: .info, title: t("subscriptions__allowance_payment_in_progress")) + return + } + sheets.hideSheetBeforePerforming(reason: "Paying payment request") { + _ = paymentRequests.requestPresentation(request) + } } }, onReject: paymentRequests.canDismiss(request) ? { @@ -377,6 +385,7 @@ struct PaymentRequestsView: View { @EnvironmentObject private var navigation: NavigationViewModel @EnvironmentObject private var sheets: SheetViewModel @Environment(PaykitPaymentRequestManager.self) private var paymentRequests + @Environment(PaykitAllowanceManager.self) private var allowances var body: some View { Group { @@ -502,7 +511,9 @@ struct PaymentRequestsView: View { private func historyDate(for request: PaykitPaymentRequest) -> String { guard let createdAt = request.createdAt else { return status(for: request) } - return Self.dateFormatter.string(from: createdAt) + let date = Self.dateFormatter.string(from: createdAt) + guard allowances.autoPaidRequestIds.contains(request.id) else { return date } + return date + " · " + t("subscriptions__allowance_auto_paid") } private static let dateFormatter: DateFormatter = { diff --git a/Bitkit/Views/Security/PaymentAuthorization.swift b/Bitkit/Views/Security/PaymentAuthorization.swift new file mode 100644 index 000000000..811a5619d --- /dev/null +++ b/Bitkit/Views/Security/PaymentAuthorization.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// Asks for the same PIN or biometric check as a payment when the user requires one for payments. +@MainActor +@Observable +final class PaymentAuthorization { + fileprivate(set) var isAskingForPin = false + fileprivate(set) var biometricErrorMessage: String? + @ObservationIgnored private var pinContinuation: CheckedContinuation? + + func authorize(settings: SettingsViewModel) async -> Bool { + guard settings.requirePinForPayments && settings.pinEnabled else { return true } + if settings.useBiometrics && BiometricAuth.isAvailable { + switch await BiometricAuth.authenticate() { + case .success: + return true + case .cancelled: + return false + case let .failed(message): + biometricErrorMessage = message + return false + } + } + resolvePin(false) + isAskingForPin = true + return await withCheckedContinuation { pinContinuation = $0 } + } + + fileprivate func resolvePin(_ approved: Bool) { + isAskingForPin = false + let continuation = pinContinuation + pinContinuation = nil + continuation?.resume(returning: approved) + } + + fileprivate func dismissBiometricError() { + biometricErrorMessage = nil + } +} + +private struct PaymentAuthorizationModifier: ViewModifier { + let authorization: PaymentAuthorization + + func body(content: Content) -> some View { + content + .sheet(isPresented: Binding( + get: { authorization.isAskingForPin }, + set: { + if !$0 { + authorization.resolvePin(false) + } + } + )) { + SendPinScreen( + onCancel: { authorization.resolvePin(false) }, + onPinVerified: { authorization.resolvePin(true) } + ) + } + .alert( + t("security__bio_error_title"), + isPresented: Binding( + get: { authorization.biometricErrorMessage != nil }, + set: { + if !$0 { + authorization.dismissBiometricError() + } + } + ) + ) { + Button(t("common__ok")) {} + } message: { + Text(authorization.biometricErrorMessage ?? "") + } + .onDisappear { authorization.resolvePin(false) } + } +} + +extension View { + func paymentAuthorization(_ authorization: PaymentAuthorization) -> some View { + modifier(PaymentAuthorizationModifier(authorization: authorization)) + } +} diff --git a/Bitkit/Views/Subscriptions/AllowancesView.swift b/Bitkit/Views/Subscriptions/AllowancesView.swift new file mode 100644 index 000000000..c46df7ae4 --- /dev/null +++ b/Bitkit/Views/Subscriptions/AllowancesView.swift @@ -0,0 +1,780 @@ +import SwiftUI + +// MARK: - Allowances tab + +struct AllowancesTab: View { + @Environment(PaykitAllowanceManager.self) private var allowances + @EnvironmentObject private var sheets: SheetViewModel + + var body: some View { + Group { + if allowances.entries.isEmpty { + emptyState + } else { + list + } + } + .task { + await allowances.refresh() + } + } + + private var list: some View { + TimelineView(.periodic(from: .now, by: 60)) { context in + LazyVStack(spacing: 12) { + ForEach(allowances.entries) { entry in + Button { + let route: SubscriptionSheetItem.Route = entry.primary.isAnswerable + ? .allowanceReview(entry) + : .allowanceDetail(entry) + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: route)) + } label: { + AllowanceRow(entry: entry, now: context.date) + } + .buttonStyle(.plain) + } + } + .padding(.top, 32) + .padding(.bottom, ScreenLayout.floatingFooterClearance) + } + } + + private var emptyState: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer() + + Image("group") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 256, height: 256) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + + Spacer().frame(height: 32) + + DisplayText(t("subscriptions__allowances_empty_headline"), accentColor: .purpleAccent) + Spacer().frame(height: 8) + BodyMText(t("subscriptions__allowances_empty_description"), textColor: .white64) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, ScreenLayout.floatingFooterClearance) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("AllowancesEmpty") + } +} + +struct AllowanceRow: View { + @Environment(PaykitAllowanceManager.self) private var allowances + @EnvironmentObject private var currency: CurrencyViewModel + + let entry: PaykitAllowanceEntry + let now: Date + + private var status: PaykitAllowance.Status { + entry.status(at: now) + } + + var body: some View { + HStack(spacing: 16) { + AllowanceCounterpartyAvatar(counterparty: entry.counterparty, size: 40) + + VStack(alignment: .leading, spacing: 0) { + AllowanceCounterpartyName(counterparty: entry.counterparty) + CaptionBText(subtitle, textColor: .white64) + .lineLimit(1) + .accessibilityIdentifier("AllowanceRowStatus") + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 0) { + AllowanceMoney(usd: entry.limits?.monthlyUsd, sats: entry.monthlyLimitSats, size: .bodyMSB) + CaptionBText(t("subscriptions__allowance_monthly_limit"), textColor: .white64) + .lineLimit(1) + } + } + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .opacity(isInactive ? 0.64 : 1) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("AllowanceRow-\(entry.id)") + } + + private var isInactive: Bool { + switch status { + case .ended, .declined, .expired, .conflicted: true + default: false + } + } + + private var subtitle: String { + let perPayment = AllowanceAmountText.perPayment(entry, currency: currency) + switch status { + case .active: + return [t("subscriptions__allowance_status_active"), perPayment].compactMap { $0 }.joined(separator: " · ") + case .awaitingAnswer: + return t("subscriptions__allowance_status_waiting") + case .awaitingMyAnswer: + return t("subscriptions__allowance_status_needs_answer") + case .notYetActive: + return t("subscriptions__allowance_status_scheduled") + case .expired: + return t("subscriptions__allowance_status_expired") + case .declined: + return t("subscriptions__allowance_status_declined") + case .conflicted: + return t("subscriptions__allowance_status_conflicted") + case .ended: + let paid = allowances.autoPaidSats(for: entry) + guard paid > 0 else { return t("subscriptions__allowance_status_ended") } + return t("subscriptions__allowance_status_ended") + " · " + + t("subscriptions__allowance_paid_automatically", variables: ["amount": AllowanceAmountText.fiat(sats: paid, currency: currency)]) + } + } +} + +// MARK: - Shared pieces + +struct AllowanceCounterpartyAvatar: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let counterparty: String + let size: CGFloat + + var body: some View { + if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, counterparty) }) { + PubkyContactAvatar(contact: contact, size: size) + } else { + ContactAvatarLetter(source: counterparty, size: size) + } + } +} + +struct AllowanceCounterpartyName: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let counterparty: String + + var body: some View { + BodyMSBText(name) + .lineLimit(1) + } + + private var name: String { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, counterparty) }?.displayName + ?? PubkyPublicKeyFormat.displayTruncated(counterparty) + } +} + +/// A limit shown in dollars: the label the Allower picked when there is one, otherwise the BTC terms at today's rate. +struct AllowanceMoney: View { + @EnvironmentObject private var currency: CurrencyViewModel + + enum Size { + case bodyMSB + case title + } + + let usd: Decimal? + let sats: UInt64? + var size: Size = .bodyMSB + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 3) { + text("$", color: .white64) + text(amount, color: .textPrimary) + } + } + + private var amount: String { + if let usd { + return AllowanceAmountText.formatted(usd) + } + guard let sats else { return "—" } + return AllowanceAmountText.limitValue(sats: sats, currency: currency) + } + + @ViewBuilder + private func text(_ value: String, color: Color) -> some View { + switch size { + case .bodyMSB: BodyMSBText(value, textColor: color) + case .title: TitleText(value, textColor: color) + } + } +} + +enum AllowanceAmountText { + static func formatted(_ usd: Decimal) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + formatter.locale = Locale(identifier: "en_US") + return formatter.string(from: usd as NSDecimalNumber) ?? "\(usd)" + } + + static func short(_ usd: Decimal) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 2 + formatter.locale = Locale(identifier: "en_US") + return "$" + (formatter.string(from: usd as NSDecimalNumber) ?? "\(usd)") + } + + @MainActor + static func fiatValue(sats: UInt64, currency: CurrencyViewModel) -> String { + guard let converted = currency.convert(sats: sats, to: "USD") else { return "—" } + return formatted(converted.value) + } + + @MainActor + static func fiat(sats: UInt64, currency: CurrencyViewModel) -> String { + "$" + fiatValue(sats: sats, currency: currency) + } + + /// A limit set in whole dollars on the other wallet, shown back from its BTC terms at today's rate: rounded to the + /// dollar so a small rate move does not turn $5 into $4.99. + @MainActor + static func limitValue(sats: UInt64, currency: CurrencyViewModel) -> String { + guard let converted = currency.convert(sats: sats, to: "USD") else { return "—" } + guard converted.value >= 1 else { return formatted(converted.value) } + var rounded = Decimal() + var value = converted.value + NSDecimalRound(&rounded, &value, 0, .plain) + return formatted(rounded) + } + + @MainActor + static func perPayment(_ entry: PaykitAllowanceEntry, currency: CurrencyViewModel) -> String? { + if let usd = entry.limits?.perPaymentUsd { + return t("subscriptions__allowance_per_payment_short", variables: ["amount": short(usd)]) + } + guard let sats = entry.perPaymentMaxSats else { return nil } + return t("subscriptions__allowance_per_payment_short", variables: ["amount": "$" + limitValue(sats: sats, currency: currency)]) + } +} + +private struct AllowanceLimitsGrid: View { + @EnvironmentObject private var currency: CurrencyViewModel + + let entry: PaykitAllowanceEntry + + var body: some View { + HStack(alignment: .top, spacing: 16) { + cell( + title: t("subscriptions__allowance_per_payment"), + usd: entry.limits?.perPaymentUsd, + sats: entry.perPaymentMaxSats, + identifier: "AllowancePerPaymentValue" + ) + cell( + title: t("subscriptions__allowance_each_month"), + usd: entry.limits?.monthlyUsd, + sats: entry.monthlyLimitSats, + identifier: "AllowanceMonthlyValue" + ) + } + } + + private func cell(title: String, usd: Decimal?, sats: UInt64?, identifier: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(title.localizedUppercase, textColor: .white64) + BodySSBText(t( + "subscriptions__allowance_up_to", + variables: ["amount": usd.map { "$" + AllowanceAmountText.formatted($0) } ?? sats.map { "$" + AllowanceAmountText.limitValue( + sats: $0, + currency: currency + ) } ?? "—"] + )) + .accessibilityIdentifier(identifier) + if let sats { + CaptionText("₿ " + sats.formattedWithSpaces, textColor: .white64) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct AllowanceCounterpartyCard: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let counterparty: String + + var body: some View { + HStack(spacing: 16) { + AllowanceCounterpartyAvatar(counterparty: counterparty, size: 48) + VStack(alignment: .leading, spacing: 0) { + CaptionMText(PubkyPublicKeyFormat.displayTruncated(counterparty).localizedUppercase, textColor: .white64) + AllowanceCounterpartyName(counterparty: counterparty) + } + Spacer() + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("AllowanceCounterparty") + } +} + +extension UInt64 { + var formattedWithSpaces: String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.groupingSeparator = " " + formatter.usesGroupingSeparator = true + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + } +} + +// MARK: - Choose a contact + +struct AllowanceContactView: View { + @EnvironmentObject private var contactsManager: ContactsManager + + let onSelect: (PubkyContact) -> Void + + var body: some View { + VStack(spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_choose_contact")) + + if contactsManager.contacts.isEmpty { + BodyMText(t("subscriptions__allowance_no_contacts"), textColor: .white64) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 16) + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(contactsManager.contacts) { contact in + Button { + onSelect(contact) + } label: { + HStack(spacing: 16) { + PubkyContactAvatar(contact: contact, size: 48) + VStack(alignment: .leading, spacing: 0) { + CaptionMText(contact.profile.truncatedPublicKey.localizedUppercase, textColor: .white64) + BodyMSBText(contact.displayName) + } + Spacer() + } + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("AllowanceContact-\(contact.displayName)") + CustomDivider() + } + } + } + } + } + .padding(.horizontal, 16) + } +} + +// MARK: - Set Allowance + +struct SetAllowanceView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var settings: SettingsViewModel + @Environment(PaykitAllowanceManager.self) private var allowances + @State private var authorization = PaymentAuthorization() + + let contact: PubkyContact + let onBack: () -> Void + let onSaved: () -> Void + + @State private var perPaymentIndex = 1 + @State private var monthlyIndex = 2 + + private var perPaymentUsd: Decimal { + PaykitAllowanceLimits.perPaymentStopsUsd[perPaymentIndex] + } + + private var monthlyUsd: Decimal { + PaykitAllowanceLimits.monthlyStopsUsd[monthlyIndex] + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_set_title"), showBackButton: true, onBack: onBack) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + AllowanceCounterpartyCard(counterparty: contact.publicKey) + + BodyMText( + t("subscriptions__allowance_set_explanation", variables: ["name": contact.displayName]), + textColor: .white64 + ) + + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("subscriptions__allowance_payment_limit").localizedUppercase, textColor: .white64) + .padding(.vertical, 16) + AllowanceStepSlider( + stops: PaykitAllowanceLimits.perPaymentStopsUsd, + selectedIndex: $perPaymentIndex, + identifier: "AllowancePerPayment" + ) + } + + CustomDivider() + + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("subscriptions__allowance_monthly_allowance").localizedUppercase, textColor: .white64) + .padding(.vertical, 16) + AllowanceStepSlider( + stops: PaykitAllowanceLimits.monthlyStopsUsd, + selectedIndex: $monthlyIndex, + identifier: "AllowanceMonthly" + ) + } + + CustomDivider() + + BodySText( + t( + "subscriptions__allowance_set_summary", + variables: [ + "perPayment": AllowanceAmountText.short(perPaymentUsd), + "monthly": AllowanceAmountText.short(monthlyUsd), + ] + ), + textColor: .white64 + ) + .accessibilityIdentifier("AllowanceSummary") + } + .padding(.bottom, 16) + } + + CustomButton(title: t("subscriptions__allowance_save"), isLoading: allowances.isWorking) { + await save() + } + .accessibilityIdentifier("AllowanceSave") + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SetAllowance") + .paymentAuthorization(authorization) + } + + private func save() async { + guard let perPaymentSats = currency.convert(fiatAmount: NSDecimalNumber(decimal: perPaymentUsd).doubleValue, from: "USD"), + let monthlySats = currency.convert(fiatAmount: NSDecimalNumber(decimal: monthlyUsd).doubleValue, from: "USD") + else { + app.toast(PaykitAllowanceError.unavailable) + return + } + let limits = PaykitAllowanceLimits( + perPaymentUsd: perPaymentUsd, + monthlyUsd: monthlyUsd, + perPaymentSats: perPaymentSats, + monthlySats: monthlySats + ) + guard await authorization.authorize(settings: settings) else { return } + do { + try await allowances.propose(to: contact, limits: limits) + onSaved() + } catch { + app.toast(error) + } + } +} + +/// A slider that snaps to fixed stops, drawn like the Figma allowance limits: a track, a tick per stop, and a knob. +struct AllowanceStepSlider: View { + let stops: [Decimal] + @Binding var selectedIndex: Int + let identifier: String + + private let knobSize: CGFloat = 32 + private let trackHeight: CGFloat = 8 + + var body: some View { + VStack(spacing: 8) { + GeometryReader { geometry in + let width = geometry.size.width + ZStack(alignment: .leading) { + Capsule() + .fill(Color.purpleAccent.opacity(0.32)) + .frame(height: trackHeight) + Capsule() + .fill(Color.purpleAccent) + .frame(width: position(for: selectedIndex, width: width), height: trackHeight) + ForEach(stops.indices, id: \.self) { index in + RoundedRectangle(cornerRadius: 2) + .fill(Color.white) + .frame(width: 4, height: 16) + .offset(x: min(max(position(for: index, width: width) - 2, 0), width - 4)) + } + Circle() + .fill(Color.purpleAccent) + .frame(width: knobSize, height: knobSize) + .overlay(Circle().fill(Color.white).frame(width: 16, height: 16)) + .offset(x: position(for: selectedIndex, width: width) - knobSize / 2) + } + .frame(height: knobSize) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + select(nearestIndex(to: value.location.x, width: width)) + } + ) + } + .frame(height: knobSize) + + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + ForEach(stops.indices, id: \.self) { index in + Button { + select(index) + } label: { + CaptionMText(AllowanceAmountText.short(stops[index]), textColor: .textPrimary) + .frame(width: labelWidth, alignment: alignment(for: index)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .offset(x: labelOffset(for: index, width: geometry.size.width)) + .accessibilityIdentifier("\(identifier)Stop-\(index)") + } + } + } + .frame(height: 18) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier(identifier) + .accessibilityValue(AllowanceAmountText.short(stops[selectedIndex])) + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: select(selectedIndex + 1) + case .decrement: select(selectedIndex - 1) + @unknown default: break + } + } + } + + private func select(_ index: Int) { + let clamped = min(max(index, 0), stops.count - 1) + guard clamped != selectedIndex else { return } + selectedIndex = clamped + Haptics.play(.light) + } + + private func position(for index: Int, width: CGFloat) -> CGFloat { + guard stops.count > 1 else { return width / 2 } + return width * CGFloat(index) / CGFloat(stops.count - 1) + } + + private func nearestIndex(to x: CGFloat, width: CGFloat) -> Int { + guard stops.count > 1, width > 0 else { return 0 } + return Int((x / width * CGFloat(stops.count - 1)).rounded()) + } + + private let labelWidth: CGFloat = 56 + + private func labelOffset(for index: Int, width: CGFloat) -> CGFloat { + if index == 0 { + return 0 + } + if index == stops.count - 1 { + return width - labelWidth + } + return position(for: index, width: width) - labelWidth / 2 + } + + private func alignment(for index: Int) -> Alignment { + if index == 0 { + return .leading + } + if index == stops.count - 1 { + return .trailing + } + return .center + } +} + +// MARK: - Review (the side that answers a proposal) + +struct AllowanceReviewView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var contactsManager: ContactsManager + @EnvironmentObject private var settings: SettingsViewModel + @Environment(PaykitAllowanceManager.self) private var allowances + @State private var authorization = PaymentAuthorization() + + let entry: PaykitAllowanceEntry + + private var counterpartyName: String { + contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, entry.counterparty) }?.displayName + ?? PubkyPublicKeyFormat.displayTruncated(entry.counterparty) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_title")) + + DisplayText( + entry.role == .allowee + ? t("subscriptions__allowance_offer_headline", variables: ["name": counterpartyName]) + : t("subscriptions__allowance_request_headline", variables: ["name": counterpartyName]), + accentColor: .purpleAccent + ) + .padding(.top, 16) + .padding(.bottom, 16) + + AllowanceCounterpartyCard(counterparty: entry.counterparty) + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + AllowanceLimitsGrid(entry: entry) + .padding(.top, 24) + + BodyMText( + entry.role == .allowee + ? t("subscriptions__allowance_offer_explanation", variables: ["name": counterpartyName]) + : t("subscriptions__allowance_request_explanation", variables: ["name": counterpartyName]), + textColor: .white64 + ) + .padding(.top, 24) + + Spacer() + + HStack(spacing: 16) { + CustomButton(title: t("subscriptions__allowance_decline"), variant: .secondary, isDisabled: allowances.isWorking) { + await respond(accept: false) + } + .accessibilityIdentifier("AllowanceDecline") + CustomButton(title: t("subscriptions__allowance_accept"), isLoading: allowances.isWorking) { + await respond(accept: true) + } + .accessibilityIdentifier("AllowanceAccept") + } + .padding(.bottom, 16) + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("AllowanceReview") + .paymentAuthorization(authorization) + .task { + // Another sheet's dismissal can close this one right after it opens; only a sheet the user saw counts. + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + await allowances.markProposalPresented(entry) + } + } + + private func respond(accept: Bool) async { + // Accepting as the payer grants spending power, like a payment. + if accept, entry.role == .allower { + guard await authorization.authorize(settings: settings) else { return } + } + do { + if accept { + try await allowances.accept(entry) + } else { + try await allowances.reject(entry) + } + sheets.hideSheet(reason: accept ? "Allowance accepted" : "Allowance declined") + } catch { + app.toast(error) + } + } +} + +// MARK: - Detail + +struct AllowanceDetailView: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var currency: CurrencyViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var settings: SettingsViewModel + @Environment(PaykitAllowanceManager.self) private var allowances + @State private var authorization = PaymentAuthorization() + + let entry: PaykitAllowanceEntry + + private var current: PaykitAllowanceEntry { + allowances.entry(id: entry.id) ?? entry + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: t("subscriptions__allowance_title")) + + AllowanceCounterpartyCard(counterparty: current.counterparty) + .padding(16) + .background(Color.gray6) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + AllowanceLimitsGrid(entry: current) + .padding(.top, 24) + + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("subscriptions__allowance_paid_so_far").localizedUppercase, textColor: .white64) + BodySSBText(AllowanceAmountText.fiat(sats: allowances.autoPaidSats(for: current), currency: currency)) + .accessibilityIdentifier("AllowancePaidSoFar") + } + .padding(.top, 24) + + BodyMText(explanation, textColor: .white64) + .padding(.top, 24) + .accessibilityIdentifier("AllowanceDetailStatus") + + Spacer() + + if current.canEnd { + SwipeButton( + title: current.primary.lifecycleState == .proposed + ? t("subscriptions__allowance_swipe_withdraw") + : t("subscriptions__allowance_swipe_end"), + accentColor: .purpleAccent, + isLoading: allowances.isWorking + ) { + if current.role == .allower { + guard await authorization.authorize(settings: settings) else { throw CancellationError() } + } + do { + try await allowances.end(current) + sheets.hideSheet(reason: "Allowance ended") + } catch { + app.toast(error) + throw error + } + } + .padding(.bottom, 16) + } + } + .padding(.horizontal, 16) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("AllowanceDetail") + .paymentAuthorization(authorization) + } + + private var explanation: String { + switch current.status(at: Date()) { + case .active: + current.role == .allower + ? t("subscriptions__allowance_detail_active_allower") + : t("subscriptions__allowance_detail_active_allowee") + case .awaitingAnswer: + t("subscriptions__allowance_status_waiting") + case .awaitingMyAnswer: + t("subscriptions__allowance_status_needs_answer") + case .notYetActive: + t("subscriptions__allowance_status_scheduled") + case .expired: + t("subscriptions__allowance_status_expired") + case .declined: + t("subscriptions__allowance_status_declined") + case .ended: + t("subscriptions__allowance_detail_ended") + case .conflicted: + t("subscriptions__allowance_status_conflicted") + } + } +} diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 36854e4d7..24e25c5b1 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -12,6 +12,10 @@ struct SubscriptionSheetItem: SheetItem { case details(PaykitSubscription) case cancel(PaykitSubscription) case payment(SendRoute) + case allowanceContact + case allowanceSet(PubkyContact) + case allowanceReview(PaykitAllowanceEntry) + case allowanceDetail(PaykitAllowanceEntry) } let route: Route @@ -22,11 +26,13 @@ struct SubscriptionSheetItem: SheetItem { struct SubscriptionsView: View { private enum Tab: String, CustomStringConvertible { case overview + case allowances case payments var description: String { switch self { case .overview: t("subscriptions__overview") + case .allowances: t("subscriptions__allowances") case .payments: t("subscriptions__payments") } } @@ -74,6 +80,8 @@ struct SubscriptionsView: View { Group { if selectedTab == .payments { PaymentRequestsView() + } else if selectedTab == .allowances { + AllowancesTab() } else if !hasVisibleSubscriptions { emptyState } else { @@ -119,6 +127,7 @@ struct SubscriptionsView: View { selectedTab: $selectedTab, tabItems: [ TabItem(.overview), + TabItem(.allowances), TabItem(.payments, badge: paymentRequests.pendingRequests.count), ], inactiveColor: .white.opacity(0.5) @@ -149,6 +158,11 @@ struct SubscriptionsView: View { sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create)) } .accessibilityIdentifier("SubscriptionCreate") + } else if selectedTab == .allowances { + CustomButton(title: t("subscriptions__allowance_add")) { + sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .allowanceContact)) + } + .accessibilityIdentifier("AllowanceAdd") } else { PaymentRequestsFooterButton() } @@ -618,6 +632,18 @@ struct SubscriptionSheet: View { cancel(subscription) case let .payment(sendRoute): SendSheet(config: SendSheetItem(initialRoute: sendRoute), isEmbedded: true) + case .allowanceContact: + AllowanceContactView { route = .allowanceSet($0) } + case let .allowanceSet(contact): + SetAllowanceView( + contact: contact, + onBack: { route = .allowanceContact }, + onSaved: { sheets.hideSheet(reason: "Allowance proposed") } + ) + case let .allowanceReview(entry): + AllowanceReviewView(entry: entry) + case let .allowanceDetail(entry): + AllowanceDetailView(entry: entry) } } .task { diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 0cde300eb..3d303cfa1 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1167,6 +1167,7 @@ struct SendConfirmationView: View { var lightningPaymentSubmitted = false var originalPaymentIdentity: String? var privatePaymentListOutcome = PrivatePaymentListSendOutcome.definitePreBroadcastFailure + var manualAllowanceAttemptId: String? do { try validateIncomingPaymentRequestContext(contactPaymentContext) @@ -1201,6 +1202,13 @@ struct SendConfirmationView: View { return } + if let incomingPaymentRequest, let preparedPaymentProof { + manualAllowanceAttemptId = try await PaykitAllowanceExecutor.shared.beginManualPayment( + incomingPaymentRequest, + paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier + ) + } + if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice { let amount = wallet.sendAmountSats ?? invoice.amountSatoshis // Set the amount for other screens @@ -1214,6 +1222,9 @@ struct SendConfirmationView: View { paymentHash: paymentHash ) } + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.manualLightningPaymentSent(attemptId: manualAllowanceAttemptId, paymentHash: paymentHash) + } createdMetadataPaymentId = paymentHash await createPreActivityMetadata(paymentId: paymentHash, paymentHash: paymentHash) @@ -1304,6 +1315,9 @@ struct SendConfirmationView: View { onAuthorized: { _ in onchainPaymentStarted = true privatePaymentListOutcome = .uncertain + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.manualOnchainPaymentSending(attemptId: manualAllowanceAttemptId) + } }, send: { beforeBroadcastAttempt in try await wallet.send( @@ -1340,6 +1354,9 @@ struct SendConfirmationView: View { title: t("wallet__onchain_broadcast_rejected"), description: t("wallet__onchain_broadcast_rejected_note", variables: ["reason": reason]) ) + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .unknown) + } await navigationPath.append(Self.onchainPendingRoute(txid: rejectedTxid, requestId: incomingPaymentRequest?.id)) return case let .unknown(unknownTxid): @@ -1350,11 +1367,21 @@ struct SendConfirmationView: View { title: t("wallet__onchain_broadcast_unconfirmed"), description: t("wallet__onchain_broadcast_unconfirmed_note") ) + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .unknown) + } await navigationPath.append(Self.onchainPendingRoute(txid: unknownTxid, requestId: incomingPaymentRequest?.id)) return } privatePaymentListOutcome = .succeeded await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment( + attemptId: manualAllowanceAttemptId, + outcome: .succeeded, + transactionId: txid + ) + } var proofSaved = true if let incomingPaymentRequest, let preparedPaymentProof { proofSaved = await PaykitPaymentProofService.shared.completeOnchainPayment( @@ -1399,6 +1426,9 @@ struct SendConfirmationView: View { ) } } catch is CancellationError { + if let manualAllowanceAttemptId, !lightningPaymentSubmitted, !onchainPaymentStarted { + await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .failed) + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } @@ -1436,6 +1466,9 @@ struct SendConfirmationView: View { case .unresolved, .duplicate, .outcomeNotSaved, .localFollowupNotSaved, .retryConstruction, .retryUnavailable, .preDispatchCleanupFailed: await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain) + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment(attemptId: manualAllowanceAttemptId, outcome: .unknown) + } shouldCancelPaymentProof = false app.toast(attemptError) await navigationPath.append(Self.onchainPendingRoute(requestId: incomingPaymentRequest?.id)) @@ -1444,10 +1477,16 @@ struct SendConfirmationView: View { break } } + await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) + if let manualAllowanceAttemptId { + await PaykitAllowanceExecutor.shared.finishManualPayment( + attemptId: manualAllowanceAttemptId, + outcome: lightningPaymentSubmitted || onchainPaymentStarted ? .unknown : .failed + ) + } if shouldCancelPaymentProof, let incomingPaymentRequest { await PaykitPaymentProofService.shared.cancelPreparation(incomingPaymentRequest) } - await contactPaymentContext?.resolvePrivatePaymentListConsumption(privatePaymentListOutcome) Logger.error("Payment failed: \(error)") if let paymentId = createdMetadataPaymentId { diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift new file mode 100644 index 000000000..e9a74bc3e --- /dev/null +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -0,0 +1,1819 @@ +@testable import Bitkit +import Combine +import Foundation +import Paykit +import XCTest + +final class PaykitAllowanceExecutorTests: XCTestCase { + private typealias Fixtures = PaykitAllowanceFixtures + + private static let admissionCalls = [ + "evaluateAllowanceCandidates", + "claimForExecution", + "recordAcceptanceIntent", + "acceptPaymentRequestAutomatically", + "reserveAutomaticPayment", + "receivePrivateMessages", + "beginPaymentExecution", + "consumePaymentList", + "ensurePaymentAllowed", + "prepareProof", + "associateLightningPayment", + "payLightning", + "resolvePaymentList", + ] + + // MARK: Admission + + func testUncoveredRequestNeverReachesTheSdk() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let uncovering: [[PaykitAllowance]] = [ + [], + [Fixtures.allowance(role: .allowee)], + [Fixtures.allowance(state: .proposed)], + [Fixtures.allowance(state: .ended)], + [Fixtures.allowance(counterparty: Fixtures.otherCounterpartyKey)], + ] + + for allowances in uncovering { + let result = await harness.executor.autoPay(request, allowances: allowances, identity: Fixtures.identityKey) + XCTAssertEqual(result, .notCovered) + } + XCTAssertEqual(harness.log.entries, []) + } + + func testCoveredLightningRequestRunsAdmissionInOrderAndHandsOffToTheNode() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .started) + let log = harness.log.entries + XCTAssertEqual(log.filter(Self.admissionCalls.contains), Self.admissionCalls) + let resolveIndex = try XCTUnwrap(log.firstIndex(of: "resolve")) + let acceptIndex = try XCTUnwrap(log.firstIndex(of: "acceptPaymentRequestAutomatically")) + XCTAssertLessThan(resolveIndex, acceptIndex) + XCTAssertFalse(log.contains("recordPaymentOutcome")) + + let evaluatedTimes = await harness.sdk.evaluatedTrustedTimes + XCTAssertEqual(evaluatedTimes, [PaykitAllowanceTime.format(Fixtures.now)]) + let selections = await harness.sdk.selections + XCTAssertEqual(selections.map(\.allowanceId), [Fixtures.walletAllowanceId]) + let acceptedEndpoints = await harness.sdk.acceptedEndpointIdentifiers + XCTAssertEqual(acceptedEndpoints, [Fixtures.lightningIdentifier]) + let reservedRevisions = await harness.sdk.reservedAssociationRevisions + XCTAssertEqual(reservedRevisions, [1]) + let preparedProofs = await harness.payer.preparedProofs + XCTAssertEqual( + preparedProofs, + [.init(appId: AllowanceHarness.paymentAppId, endpoint: Fixtures.lightningIdentifier, allowanceId: Fixtures.walletAllowanceId)] + ) + let claimed = await harness.payer.claimedRequestIds + XCTAssertEqual(claimed, [request.id]) + let intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [request.id], "The request stays this device's own once accepted, so a failed payment comes back as manual") + XCTAssertFalse(harness.log.entries.contains("discardAcceptanceIntent")) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain], "A hand-off to the node keeps the payment list consumed") + let associatedHashes = await harness.payer.associatedPaymentHashes + XCTAssertEqual(associatedHashes, [AllowanceHarness.paymentHash]) + let payments = await harness.payer.lightningPayments + XCTAssertEqual(payments, [.init(bolt11: AllowanceHarness.invoice, sats: nil)]) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.count, 1) + let entry = try XCTUnwrap(journal.first) + XCTAssertEqual(entry.attemptId, AllowanceSdkMock.automaticAttemptId) + XCTAssertEqual(entry.stage, .sent) + XCTAssertTrue(entry.isAutomatic) + XCTAssertEqual(entry.requestId, request.id) + XCTAssertEqual(entry.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(entry.amountSats, 1000) + XCTAssertEqual(entry.paymentHash, AllowanceHarness.paymentHash) + XCTAssertEqual(entry.paymentEndpointIdentifier, Fixtures.lightningIdentifier) + let isHandling = await harness.executor.isHandling(request.id) + XCTAssertFalse(isHandling) + } + + func testRequestWaitsWithoutAcceptanceWhileThePayeesPaymentListIsPending() async throws { + let harness = AllowanceHarness() + await harness.payer.setResolveError(PaykitAllowanceError.paymentListPending) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .deferred) + let log = harness.log.entries + XCTAssertTrue(log.contains("resolve")) + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("markPaymentManualOnly")) + } + + @MainActor + func testManagerKeepsCoveredRequestsOffTheSendSheetUntilLightningIsReady() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + var ready = false + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { ready } + ) + await manager.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + + let handledWhileReconnecting = await manager.processIncomingRequests([request]) + + XCTAssertFalse(handledWhileReconnecting) + XCTAssertFalse(harness.log.entries.contains("evaluateAllowanceCandidates")) + let waiting = await manager.isAutomaticallyHandling(request) + XCTAssertTrue(waiting) + + ready = true + let handled = await manager.processIncomingRequests([request]) + + XCTAssertTrue(handled) + XCTAssertTrue(harness.log.entries.contains("payLightning")) + } + + @MainActor + func testManagerOwnsACoveredRequestBeforeTheFirstPassAndReleasesItOnceFoundManual() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + await harness.sdk.setCandidates([AllowanceHarness.candidate(blocked: .sharedRule(code: "amount_outside_range"))]) + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { true } + ) + await manager.activate(identity: Fixtures.identityKey) + let covered = try Fixtures.paymentRequest() + let uncovered = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002", counterparty: Fixtures.otherCounterpartyKey) + + let ownsBeforeAnyPass = await manager.isAutomaticallyHandling(covered) + let ownsUncovered = await manager.isAutomaticallyHandling(uncovered) + + XCTAssertTrue(ownsBeforeAnyPass, "No sheet may open between the request arriving and the first pass over it") + XCTAssertFalse(ownsUncovered) + + _ = await manager.processIncomingRequests([covered, uncovered]) + + let ownsAfterManual = await manager.isAutomaticallyHandling(covered) + XCTAssertFalse(ownsAfterManual, "A request the pass found manual goes to the Send sheet") + } + + @MainActor + func testManagerHandsBackARequestAboveTheLimitsWhileWaitingForAChannel() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { false } + ) + await manager.activate(identity: Fixtures.identityKey) + let aboveLimit = try Fixtures.paymentRequest(amount: "0.0001") + let withinLimits = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002") + let events = AllowanceEventRecorder() + + let handedBack = await manager.processIncomingRequests([aboveLimit, withinLimits]) + + XCTAssertTrue(handedBack) + let ownsAboveLimit = await manager.isAutomaticallyHandling(aboveLimit) + let ownsWithinLimits = await manager.isAutomaticallyHandling(withinLimits) + XCTAssertFalse(ownsAboveLimit, "A request over the limits goes to the Send sheet without waiting for a channel") + XCTAssertTrue(ownsWithinLimits, "A request within the limits waits for the channel") + XCTAssertEqual(events.events.filter { + if case .limitReached = $0 { + true + } else { + false + } + }.count, 1) + XCTAssertFalse(harness.log.entries.contains("payLightning")) + } + + @MainActor + func testManagerMayCoverAContactItWasAskedToGrant() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: Fixtures.standardTerms())]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertTrue(try manager.mayCover(Fixtures.paymentRequest())) + XCTAssertFalse(try manager.mayCover(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) + XCTAssertFalse(manager.isUpdating) + } + + @MainActor + func testManagerMayNotCoverAContactWhoseAllowanceEnded() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(state: .ended, terms: Fixtures.standardTerms())]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertFalse(try manager.mayCover(Fixtures.paymentRequest()), "An ended allowance can't start covering a request") + } + + @MainActor + func testManagerKeepsADeferredRequestOffTheSendSheet() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + await harness.payer.setResolveError(PaykitAllowanceError.paymentListPending) + let manager = PaykitAllowanceManager( + sdk: harness.sdk, + executor: harness.executor, + now: { PaykitAllowanceFixtures.now }, + canPayNow: { true } + ) + await manager.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + + let handled = await manager.processIncomingRequests([request]) + + XCTAssertFalse(handled) + let waiting = await manager.isAutomaticallyHandling(request) + XCTAssertTrue(waiting) + } + + func testBlockedCandidateStaysManualWithoutAcceptance() async throws { + let harness = AllowanceHarness() + await harness.sdk.setCandidates([ + AllowanceHarness.candidate(blocked: .sharedRule(code: "amount_outside_range")), + ]) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let log = harness.log.entries + XCTAssertTrue(log.contains("evaluateAllowanceCandidates")) + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("reserveAutomaticPayment")) + XCTAssertFalse(log.contains("resolve")) + XCTAssertFalse(log.contains("payLightning")) + } + + func testOverMonthlyCapStaysManualAndNotifiesOnce() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setAccountingState(Self.stateNearTheMonthlyCap()) + let request = try Fixtures.paymentRequest() + let events = AllowanceEventRecorder() + + let first = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + let second = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(first, .manual) + XCTAssertEqual(second, .manual) + let log = harness.log.entries + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("reserveAutomaticPayment")) + XCTAssertFalse(log.contains("resolve")) + let limitEvents = events.events.filter { $0 == .limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 1000) } + XCTAssertEqual(limitEvents.count, 1) + } + + func testRequestAbovePerPaymentLimitNotifiesOnceWhenTheSdkBlocksIt() async throws { + let harness = AllowanceHarness() + await harness.sdk.setCandidates([AllowanceHarness.candidate(blocked: .sharedRule(code: "per_payment_amount_exceeded"))]) + let request = try Fixtures.paymentRequest() + let allowance = Fixtures.allowance(perPaymentMaxSats: 500) + let events = AllowanceEventRecorder() + + let first = await harness.executor.autoPay(request, allowances: [allowance], identity: Fixtures.identityKey) + let second = await harness.executor.autoPay(request, allowances: [allowance], identity: Fixtures.identityKey) + + XCTAssertEqual(first, .manual) + XCTAssertEqual(second, .manual) + let limitEvents = events.events.filter { $0 == .limitReached(counterparty: Fixtures.counterpartyKey, amountSats: 1000) } + XCTAssertEqual(limitEvents.count, 1) + } + + func testPaymentIsNotSentWhenTheSendingStageCannotBeSaved() async throws { + let harness = AllowanceHarness() + harness.store.failSaves() + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + XCTAssertFalse(harness.log.entries.contains("payLightning")) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)]) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + } + + func testReservationBlockedForAReasonOtherThanALimitRaisesNoLimitReached() async throws { + let harness = AllowanceHarness() + await harness.sdk.setAutomaticReservation(.blocked(reason: .manualOnly)) + let events = AllowanceEventRecorder() + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + XCTAssertFalse(events.events.contains { + if case .limitReached = $0 { + true + } else { + false + } + }) + } + + func testBlockedReservationMarksThePaymentManualOnly() async throws { + let harness = AllowanceHarness() + await harness.sdk.setAutomaticReservation(.blocked(reason: .sharedRule(code: "period_amount_limit"))) + let request = try Fixtures.paymentRequest() + + let result = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let manualOnly = await harness.sdk.manualOnlyOccurrences + XCTAssertEqual(manualOnly, [ + Paykit.PaymentOccurrence( + request: Paykit.PaymentRequestScope( + counterparty: request.counterparty, + paymentRequestId: request.paymentRequestId + ), + billingPeriod: nil + ), + ]) + let log = harness.log.entries + XCTAssertFalse(log.contains("beginPaymentExecution")) + XCTAssertFalse(log.contains("payLightning")) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal, []) + } + + func testBlockedBeginRecordsAFailedOutcome() async throws { + let harness = AllowanceHarness() + await harness.sdk.setBeginDecision(.blocked(reason: .manualOnly)) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.failed]) + let log = harness.log.entries + XCTAssertFalse(log.contains("consumePaymentList")) + XCTAssertFalse(log.contains("prepareProof")) + XCTAssertFalse(log.contains("payLightning")) + } + + func testClaimFailureStaysManualBeforeAnyAcceptance() async throws { + let harness = AllowanceHarness() + await harness.payer.setClaimError(PaykitPaymentRequestError.requestUnavailable) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let log = harness.log.entries + XCTAssertTrue(log.contains("claimForExecution")) + XCTAssertFalse(log.contains("acceptPaymentRequestAutomatically")) + XCTAssertFalse(log.contains("reserveAutomaticPayment")) + XCTAssertFalse(log.contains("payLightning")) + } + + func testRefusedAcceptanceDropsTheIntentButAnInterruptedOneKeepsIt() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + await harness.sdk.setAcceptError(PaykitError.Policy(code: "refused", context: "refused")) + + let refused = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(refused, .manual) + var intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [], "A refused Acceptance leaves nothing to reconcile") + + await harness.sdk.setAcceptError(PaykitError.Transport(code: "timeout", context: "interrupted")) + let interrupted = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(interrupted, .manual) + intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [request.id], "An interrupted response may follow a committed Acceptance") + XCTAssertFalse(harness.log.entries.contains("reserveAutomaticPayment")) + } + + func testNodeRejectionBeforeRoutingReleasesThePaymentListAndRecordsAFailure() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)]) + XCTAssertTrue(harness.log.entries.contains("failLightningPayment")) + } + + func testUncertainLightningSubmissionErrorKeepsTheReservation() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + await harness.payer.setLightningErrorIsDefinite(false) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .started) + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain]) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .unknown)]) + } + + func testSettlementDuringTheSendIsNotReplacedBySent() async throws { + let harness = AllowanceHarness() + let gate = await harness.payer.holdLightning() + let request = try Fixtures.paymentRequest() + await harness.executor.activate(identity: Fixtures.identityKey) + + let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("payLightning") } + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: true) + gate.open() + _ = await caller.value + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + } + + // MARK: Cancellation + + func testLightningPaymentStillStartsWhenItsCallerIsCancelledDuringTheSend() async throws { + let harness = AllowanceHarness() + let gate = await harness.payer.holdLightning() + let request = try Fixtures.paymentRequest() + + let caller = Task { await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("payLightning") } + caller.cancel() + gate.open() + let result = await caller.value + + XCTAssertEqual(result, .started) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.sent]) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [], "The settlement event, not the caller, records a Lightning outcome") + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.uncertain]) + } + + func testOnchainAutoPaymentFeeIsNotSpentOutsideTheAllowance() async throws { + let harness = AllowanceHarness(payment: AllowanceHarness.onchainPayment()) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + XCTAssertFalse(harness.log.entries.contains("claimForExecution")) + XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically")) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, []) + } + + // MARK: Settlement and recovery + + func testLightningSettlementRecordsSuccessForTheJournaledAttempt() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "attempt-1", + request: request, + stage: .sent, + paymentHash: AllowanceHarness.paymentHash + )]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + let events = AllowanceEventRecorder() + + await harness.executor.lightningPaymentSettled(paymentHash: String(repeating: "f", count: 64), succeeded: true) + let outcomesForUnknownHash = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomesForUnknownHash, []) + + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash.uppercased(), succeeded: true) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: "attempt-1", outcome: .succeeded)]) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + let paid = await harness.executor.succeededAutomaticPayments(identity: Fixtures.identityKey) + XCTAssertEqual(paid.map(\.attemptId), ["attempt-1"]) + let paidEvents = events.events.filter { + $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: AllowanceHarness.paymentHash) + } + XCTAssertEqual(paidEvents.count, 1) + XCTAssertEqual(harness.log.entries.filter { $0 == "payLightning" || $0 == "payOnchain" }, []) + } + + func testExecutionStartupErrorReleasesTheReservation() async throws { + let harness = AllowanceHarness() + await harness.sdk.setBeginError(AllowanceMockError.unsupported) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual(result, .manual) + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes, [Paykit.PaymentOutcomeReport(attemptId: AllowanceSdkMock.automaticAttemptId, outcome: .failed)]) + XCTAssertFalse(harness.log.entries.contains("payLightning")) + } + + func testRefusedAcceptanceKeepsAnIntentTheManualFlowSaved() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + await harness.payer.seedAcceptanceIntent(request.id) + await harness.sdk.setAcceptError(AllowanceMockError.unsupported) + + _ = await harness.executor.autoPay(request, allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let intents = await harness.payer.acceptanceIntents + XCTAssertEqual(intents, [request.id]) + XCTAssertFalse(harness.log.entries.contains("discardAcceptanceIntent")) + } + + func testRecoveryAnnouncesAnAutomaticPaymentItSettles() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash)]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + let events = AllowanceEventRecorder() + + await harness.executor.recover(identity: Fixtures.identityKey) + + let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) } + XCTAssertEqual(paid.count, 1) + } + + func testRecoveryAnnouncesAPaymentReconciliationAlreadySettled() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-settled", attempts: [Fixtures.attemptRecord(id: "settled", status: .succeeded)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "settled", request: request, stage: .sent, paymentHash: paidHash)]), + identity: Fixtures.identityKey + ) + let events = AllowanceEventRecorder() + + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.succeeded]) + let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) } + XCTAssertEqual(paid.count, 1) + } + + func testSettlementAndRecoveryAnnounceOnePaymentOnce() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-sent", attempts: [Fixtures.attemptRecord(id: "sent", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent", request: request, stage: .sent, paymentHash: paidHash)]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + await harness.executor.activate(identity: Fixtures.identityKey) + let events = AllowanceEventRecorder() + + await harness.executor.lightningPaymentSettled(paymentHash: paidHash, succeeded: true) + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.lightningPaymentSettled(paymentHash: paidHash, succeeded: true) + + let paid = events.events.filter { $0 == .paidAutomatically(counterparty: Fixtures.counterpartyKey, amountSats: 1000, paymentId: paidHash) } + XCTAssertEqual(paid.count, 1) + } + + func testLimitCheckIgnoresAnExpiredAllowance() async throws { + let harness = AllowanceHarness() + let active = Fixtures.allowance(allowanceId: "active", perPaymentMaxSats: 500) + let expired = Fixtures.allowance(allowanceId: "expired", perPaymentMaxSats: 5000, expiresAt: Fixtures.now.addingTimeInterval(-60)) + + let exceeds = try await harness.executor.exceedsLimits( + Fixtures.paymentRequest(), + allowances: [active, expired], + identity: Fixtures.identityKey + ) + + XCTAssertTrue(exceeds, "Only an allowance active now can take a request") + } + + func testPaymentListReleasedBeforeBroadcastIsNeverReleasedAgain() async throws { + let harness = AllowanceHarness() + await harness.payer.setLightningError(AllowanceMockError.unsupported) + + _ = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + let listOutcomes = await harness.payer.paymentListOutcomes + XCTAssertEqual(listOutcomes, [.definitePreBroadcastFailure]) + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [], "A second release could clear another payment's consumption of the same version") + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.paymentListVersion), [nil]) + } + + func testFailedAutomaticPaymentReleasesThePaymentListItConsumed() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry( + attemptId: "sent", request: request, stage: .sent, paymentHash: AllowanceHarness.paymentHash, paymentListVersion: 7 + )]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + + await harness.executor.lightningPaymentSettled(paymentHash: AllowanceHarness.paymentHash, succeeded: false) + + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 7, previousVersion: 6)]) + } + + func testRecoveryReleasesThePaymentListOfAnAttemptKilledBeforeSending() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-killed", attempts: [Fixtures.attemptRecord(id: "killed", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "killed", request: request, stage: .submitted, paymentListVersion: 3)]), + identity: Fixtures.identityKey + ) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 3, previousVersion: 2)]) + } + + func testRecoveryReleasesThePaymentListOfAnAttemptReconciliationFailed() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-failed", attempts: [Fixtures.attemptRecord(id: "failed", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "failed", request: request, stage: .sent, paymentListVersion: 5)]), + identity: Fixtures.identityKey + ) + + await harness.executor.recover(identity: Fixtures.identityKey) + await harness.executor.recover(identity: Fixtures.identityKey) + + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.failed]) + let released = await harness.payer.releasedLists + XCTAssertEqual(released, [.init(publicKey: Fixtures.counterpartyKey, version: 5, previousVersion: 4)]) + } + + func testRecoveryNeverReleasesAListASettlementReleasedWhileItWaited() async throws { + let harness = AllowanceHarness() + let first = try Fixtures.paymentRequest() + let second = try Fixtures.paymentRequest(id: "550e8400-e29b-41d4-a716-446655440002") + let secondHash = String(repeating: "2", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-a", attempts: [Fixtures.attemptRecord(id: "a", status: .failed)]), + Fixtures.occurrence(requestId: "req-b", attempts: [Fixtures.attemptRecord(id: "b", status: .failed)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "a", request: first, stage: .sent, paymentListVersion: 5), + Self.journalEntry(attemptId: "b", request: second, stage: .sent, paymentHash: secondHash, paymentListVersion: 7), + ]), + identity: Fixtures.identityKey + ) + await harness.executor.activate(identity: Fixtures.identityKey) + let gate = await harness.payer.holdNextRelease() + + let recovery = Task { await harness.executor.recover(identity: Fixtures.identityKey) } + await Self.waitUntil { harness.log.entries.contains("releaseConsumedPaymentList") } + await harness.executor.lightningPaymentSettled(paymentHash: secondHash, succeeded: false) + gate.open() + await recovery.value + + let released = await harness.payer.releasedLists.map(\.version) + XCTAssertEqual(released.filter { $0 == 7 }.count, 1, "The second attempt's list is released once, by the settlement") + } + + func testRecoveryResolvesOpenAttemptsWithoutPayingAgain() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + let pendingHash = String(repeating: "2", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence(requestId: "req-prepared", attempts: [Fixtures.attemptRecord(id: "prepared", status: .prepared)]), + Fixtures.occurrence(requestId: "req-old-epoch", attempts: [Fixtures.attemptRecord(id: "old-epoch", status: .prepared, epoch: "epoch-0")]), + Fixtures.occurrence(requestId: "req-never-sent", attempts: [Fixtures.attemptRecord(id: "never-sent", status: .submitted)]), + Fixtures.occurrence(requestId: "req-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]), + Fixtures.occurrence(requestId: "req-sent-pending", attempts: [Fixtures.attemptRecord(id: "sent-pending", status: .submitted)]), + Fixtures.occurrence(requestId: "req-done", attempts: [Fixtures.attemptRecord(id: "done", status: .succeeded)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [ + Self.journalEntry(attemptId: "prepared", request: request, stage: .prepared), + Self.journalEntry(attemptId: "never-sent", request: request, stage: .submitted), + Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash), + Self.journalEntry(attemptId: "sent-pending", request: request, stage: .sent, paymentHash: pendingHash), + ]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64)), pendingHash: .pending]) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual( + Dictionary(uniqueKeysWithValues: outcomes.map { ($0.attemptId, $0.outcome) }), + ["prepared": .failed, "never-sent": .failed, "sent-paid": .succeeded, "sent-pending": .unknown] + ) + XCTAssertEqual(outcomes.count, 4) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual( + Dictionary(uniqueKeysWithValues: journal.map { ($0.attemptId, $0.stage) }), + ["prepared": .failed, "never-sent": .failed, "sent-paid": .succeeded, "sent-pending": .unknown] + ) + let payerCalls = await harness.payer.callCount + XCTAssertEqual(payerCalls, 0, "Recovery must never touch the payer, so nothing is paid twice") + XCTAssertEqual(harness.log.entries.filter { $0 == "payLightning" || $0 == "payOnchain" }, []) + let reconciliations = await harness.sdk.reconciliations + XCTAssertEqual(reconciliations.count, 0) + } + + // MARK: Manual payments + + func testManualPaymentThrowsWhenTheRequestIsAlreadyRecorded() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + await harness.sdk.setManualReservation(.blocked(reason: .paymentAlreadyRecorded)) + let request = try Fixtures.paymentRequest() + + do { + _ = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + XCTFail("Expected alreadyRecorded") + } catch PaykitAllowanceManualPaymentError.alreadyRecorded { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + let log = harness.log.entries + XCTAssertFalse(log.contains("beginPaymentExecution")) + } + + func testManualPaymentOfARequestNotPricedInBitcoinStaysOutOfTheLedger() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest(amount: "5.00", asset: PaykitAsset.usd.rawValue) + + let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + + XCTAssertNil(attemptId) + XCTAssertFalse(harness.log.entries.contains("reserveManualPayment")) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal, []) + } + + func testManualPaymentIsJournaledAndSubmittedWhenReady() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + + let attemptId = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + + XCTAssertEqual(attemptId, AllowanceSdkMock.manualAttemptId) + let journal = await harness.executor.localState(identity: Fixtures.identityKey).journal + XCTAssertEqual(journal.map(\.stage), [.submitted]) + XCTAssertEqual(journal.first?.isAutomatic, false) + + await harness.sdk.setManualReservation(.blocked(reason: .manualOnly)) + let blocked = try await harness.executor.beginManualPayment(request, paymentEndpointIdentifier: Fixtures.lightningIdentifier) + XCTAssertNil(blocked) + } + + // MARK: Trusted time and reconciliation + + func testTrustedTimeNeverMovesBackwards() async { + let harness = AllowanceHarness() + let start = Fixtures.now + + let first = await harness.executor.trustedTime(identity: Fixtures.identityKey) + harness.clock.set(start.addingTimeInterval(-3600)) + let afterClockMovedBack = await harness.executor.trustedTime(identity: Fixtures.identityKey) + harness.clock.set(start.addingTimeInterval(60)) + let afterClockMovedForward = await harness.executor.trustedTime(identity: Fixtures.identityKey) + + XCTAssertEqual(first, PaykitAllowanceTime.format(start)) + XCTAssertEqual(afterClockMovedBack, PaykitAllowanceTime.format(start)) + XCTAssertEqual(afterClockMovedForward, PaykitAllowanceTime.format(start.addingTimeInterval(60))) + let stored = await harness.executor.localState(identity: Fixtures.identityKey).lastTrustedTime + XCTAssertEqual(stored, start.addingTimeInterval(60)) + } + + func testMissingLedgerIsReconciledWithAnEmptyHistory() async throws { + let harness = AllowanceHarness() + await harness.sdk.setAccountingState(nil) + + let reconciled = try await harness.executor.ensureReconciled(identity: Fixtures.identityKey) + _ = try await harness.executor.ensureReconciled(identity: Fixtures.identityKey) + + let reconciliations = await harness.sdk.reconciliations + XCTAssertEqual(reconciliations.count, 1) + let reconciliation = try XCTUnwrap(reconciliations.first) + XCTAssertNil(reconciliation.expectedRevision) + XCTAssertTrue(reconciliation.history.associations.isEmpty) + XCTAssertTrue(reconciliation.history.occurrences.isEmpty) + XCTAssertTrue(reconciliation.history.watermarks.isEmpty) + XCTAssertEqual(reconciliation.outcomes, []) + XCTAssertEqual(reconciliation.trustedTime, PaykitAllowanceTime.format(Fixtures.now)) + XCTAssertFalse(reconciled.requiresReconciliation) + } + + func testLedgerThatRequiresReconciliationIsReconciledAtItsRevision() async throws { + let harness = AllowanceHarness() + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "3", count: 64) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 7, requiresReconciliation: true, occurrences: [ + Fixtures.occurrence(requestId: "req-prepared", attempts: [Fixtures.attemptRecord(id: "prepared", status: .prepared)]), + Fixtures.occurrence(requestId: "req-sent-paid", attempts: [Fixtures.attemptRecord(id: "sent-paid", status: .submitted)]), + ])) + harness.store.seed( + PaykitAllowanceLocalState(journal: [Self.journalEntry(attemptId: "sent-paid", request: request, stage: .sent, paymentHash: paidHash)]), + identity: Fixtures.identityKey + ) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: nil)]) + + try await harness.executor.ensureReconciled(identity: Fixtures.identityKey) + + let reconciliations = await harness.sdk.reconciliations + XCTAssertEqual(reconciliations.count, 1) + let reconciliation = try XCTUnwrap(reconciliations.first) + XCTAssertEqual(reconciliation.expectedRevision, 7) + XCTAssertEqual(reconciliation.history.occurrences.flatMap(\.attempts).map(\.attemptId), ["prepared", "sent-paid"]) + XCTAssertEqual(reconciliation.outcomes, [ + Paykit.PaymentOutcomeReport(attemptId: "prepared", outcome: .failed), + Paykit.PaymentOutcomeReport(attemptId: "sent-paid", outcome: .succeeded), + ]) + let payerCalls = await harness.payer.callCount + XCTAssertEqual(payerCalls, 0) + } + + // MARK: Trusted time vs subscription clock offset + + func testAdmissionUsesInjectedTimeWhileSubscriptionClockIsOffset() async throws { + snapshotAppDefaults(SubscriptionClock.offsetDaysKey) + UserDefaults.standard.set(400, forKey: SubscriptionClock.offsetDaysKey) + try XCTSkipUnless(SubscriptionClock.offsetDays() == 400, "The subscription clock offset is unavailable in this build") + let harness = AllowanceHarness() + try await harness.sdk.setAccountingState(Self.stateNearTheMonthlyCap()) + + let result = try await harness.executor.autoPay(Fixtures.paymentRequest(), allowances: [Fixtures.allowance()], identity: Fixtures.identityKey) + + XCTAssertEqual( + result, + .manual, + "September's paid attempts must count; the subscription clock offset would have moved the window a year ahead" + ) + let evaluatedTimes = await harness.sdk.evaluatedTrustedTimes + XCTAssertEqual(evaluatedTimes, [PaykitAllowanceTime.format(Fixtures.now)]) + XCTAssertFalse(harness.log.entries.contains("acceptPaymentRequestAutomatically")) + } + + // MARK: Manager + + @MainActor + func testAcceptingAProposalNeverAnswersAnotherContactsProposalWithTheSameId() async throws { + let harness = AllowanceHarness() + let sharedId = Fixtures.otherAllowanceId + let offer = try Fixtures.record( + allowanceId: sharedId, localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), + proposedByMe: false, lastEventAt: "2026-09-02T10:00:00Z" + ) + let ask = try Fixtures.record( + allowanceId: sharedId, counterparty: Fixtures.otherCounterpartyKey, localRole: .allower, state: .proposed, + terms: Fixtures.standardTerms(), proposedByMe: false, lastEventAt: "2026-09-01T10:00:00Z" + ) + await harness.sdk.setRecords([offer, ask]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + let presented = try XCTUnwrap(manager.proposalForPresentation()) + XCTAssertEqual(presented.role, .allowee) + XCTAssertEqual(presented.counterparty, Fixtures.counterpartyKey) + try await manager.accept(presented) + + let accepted = await harness.sdk.acceptedCounterparties + XCTAssertEqual(accepted, [Fixtures.counterpartyKey], "Accepting C's offer must not accept M's ask as payer") + } + + @MainActor + func testPresentingOneContactsProposalKeepsAnotherContactsProposalWithTheSameIdPresentable() async throws { + let harness = AllowanceHarness() + let sharedId = Fixtures.otherAllowanceId + let offer = try Fixtures.record( + allowanceId: sharedId, localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), + proposedByMe: false, lastEventAt: "2026-09-02T10:00:00Z" + ) + let ask = try Fixtures.record( + allowanceId: sharedId, counterparty: Fixtures.otherCounterpartyKey, localRole: .allower, state: .proposed, + terms: Fixtures.standardTerms(), proposedByMe: false, lastEventAt: "2026-09-01T10:00:00Z" + ) + await harness.sdk.setRecords([offer, ask]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + let first = try XCTUnwrap(manager.proposalForPresentation()) + await manager.markProposalPresented(first) + + let second = try XCTUnwrap(manager.proposalForPresentation()) + XCTAssertNotEqual(second.counterparty, first.counterparty) + } + + @MainActor + func testManagerPresentsEachGrantAsOneEntryWithItsLimitsAndLeavesInvalidHistoryOut() async throws { + let harness = AllowanceHarness() + let terms = try Fixtures.standardTerms() + await harness.sdk.setRecords([ + Fixtures.record(allowanceId: Fixtures.walletAllowanceId, terms: terms), + Fixtures.record(allowanceId: Fixtures.otherAllowanceId, counterparty: Fixtures.otherCounterpartyKey, terms: terms), + Fixtures.record(allowanceId: "allowance-invalid", historyStatus: .invalid, terms: terms), + ]) + let group = PaykitAllowanceLocalState.Group( + id: "group-1", + counterparty: Fixtures.counterpartyKey, + limits: Fixtures.limits, + allowanceIds: [Fixtures.walletAllowanceId], + createdAt: Fixtures.now + ) + harness.store.seed(PaykitAllowanceLocalState(groups: [group]), identity: Fixtures.identityKey) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + let entries = manager.entries + XCTAssertEqual(entries.map(\.id), ["group-1", "\(Fixtures.otherCounterpartyKey)/\(Fixtures.otherAllowanceId)"]) + let grant = try XCTUnwrap(entries.first) + XCTAssertEqual(grant.allowances.map(\.allowanceId), [Fixtures.walletAllowanceId]) + XCTAssertEqual(grant.limits, Fixtures.limits) + XCTAssertEqual(grant.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(grant.role, .allower) + XCTAssertEqual(grant.perPaymentMaxSats, 5000) + XCTAssertEqual(grant.monthlyLimitSats, 50000) + XCTAssertEqual(grant.status(at: Fixtures.now), .active) + XCTAssertNil(entries.last?.limits) + XCTAssertEqual(manager.entry(id: "group-1")?.primary.allowanceId, Fixtures.walletAllowanceId) + } + + @MainActor + func testManagerCoversRequestsFromTheGrantedIdentityOnly() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: "2026-09-24T09:00:00Z")]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest()), "One Encrypted Link per identity: no folder is part of coverage") + XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) + } + + @MainActor + func testManagerLeavesRequestsNotPricedInBitcoinManual() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: "2026-09-24T09:00:00Z")]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + let usdRequest = try Fixtures.paymentRequest(amount: "5.00", asset: PaykitAsset.usd.rawValue) + XCTAssertFalse(usdRequest.isAllowancePayable) + XCTAssertFalse(manager.coversRequest(usdRequest)) + let isHandling = await manager.isAutomaticallyHandling(usdRequest) + XCTAssertFalse(isHandling, "A USD request reaches the Send sheet instead of waiting for an automatic payment") + + let convertedRequest = try Fixtures.paymentRequest(conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")])) + XCTAssertTrue(convertedRequest.isAllowancePayable, "A bitcoin request that also prices a USDT rail pays over bitcoin") + XCTAssertTrue(manager.coversRequest(convertedRequest)) + } + + func testManualPaymentOverTheUsdtRailStaysOutOfTheLedger() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest(conversion: .fixed(rates: [.init(asset: "usdt", value: "100000")])) + + let attemptId = try await harness.executor.beginManualPayment( + request, + paymentEndpointIdentifier: PublicPaykitService.MethodId.usdtArbitrum.rawValue + ) + + XCTAssertNil(attemptId) + XCTAssertFalse(harness.log.entries.contains("reserveManualPayment")) + } + + @MainActor + func testGrantsNameOnlyBitcoinPaymentEndpoints() { + XCTAssertFalse(PaykitAllowanceManager.allowedPaymentEndpointIdentifiers.contains(PublicPaykitService.MethodId.usdtArbitrum.rawValue)) + XCTAssertTrue(PaykitAllowanceManager.allowedPaymentEndpointIdentifiers.contains(PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue)) + } + + // MARK: Granting + + @MainActor + func testProposeWaitsForTheContactsLinkAndThenProposesOneAllowance() async throws { + let harness = AllowanceHarness() + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + let contact = Fixtures.contact() + + for state in [Paykit.LinkedPeerState.notLinked, .linking, .recoveryRequired, .blocked] { + await harness.sdk.setPeers([Fixtures.linkedPeer(state: state)]) + do { + try await manager.propose(to: contact, limits: Fixtures.limits) + XCTFail("Expected contactNotLinked while the link is \(state)") + } catch PaykitAllowanceError.contactNotLinked { + // expected + } + } + var proposals = await harness.sdk.proposals + XCTAssertEqual(proposals.count, 0, "A grant never reaches a contact that has no usable link") + XCTAssertTrue(manager.entries.isEmpty) + + await harness.sdk.setPeers([Fixtures.linkedPeer(state: .linked)]) + try await manager.propose(to: contact, limits: Fixtures.limits) + + proposals = await harness.sdk.proposals + XCTAssertEqual(proposals.count, 1, "One link per identity means one SDK Allowance") + XCTAssertEqual(proposals.first?.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(proposals.first?.localRole, .allower) + XCTAssertEqual(proposals.first?.terms.perPaymentAmount()?.maximum(), "0.00005") + XCTAssertEqual(proposals.first?.terms.allowedPaymentEndpointIdentifiers(), PaykitAllowanceManager.allowedPaymentEndpointIdentifiers) + XCTAssertTrue(harness.log.entries.contains("processOutboundPrivateMessages")) + let entry = try XCTUnwrap(manager.entries.first) + XCTAssertEqual(entry.allowances.map(\.allowanceId), ["proposed-1"]) + XCTAssertEqual(entry.limits, Fixtures.limits) + XCTAssertEqual(entry.status(at: Fixtures.now), .awaitingAnswer) + } + + @MainActor + func testProposeIgnoresALinkedPeerThatIsAnotherContact() async throws { + let harness = AllowanceHarness() + await harness.sdk.setPeers([Fixtures.linkedPeer(counterparty: Fixtures.otherCounterpartyKey, state: .linked)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + do { + try await manager.propose(to: Fixtures.contact(), limits: Fixtures.limits) + XCTFail("Expected contactNotLinked") + } catch PaykitAllowanceError.contactNotLinked { + // expected + } + let proposals = await harness.sdk.proposals + XCTAssertEqual(proposals.count, 0) + } + + @MainActor + func testManagerCoverageUsesInjectedTimeWhileSubscriptionClockIsOffset() async throws { + snapshotAppDefaults(SubscriptionClock.offsetDaysKey) + UserDefaults.standard.set(400, forKey: SubscriptionClock.offsetDaysKey) + try XCTSkipUnless(SubscriptionClock.offsetDays() == 400, "The subscription clock offset is unavailable in this build") + let harness = AllowanceHarness() + let expiring = try Fixtures.customTerms(expiresAt: Fixtures.now.addingTimeInterval(30 * 24 * 60 * 60)) + await harness.sdk.setRecords([Fixtures.record(terms: expiring)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest())) + XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(counterparty: Fixtures.otherCounterpartyKey))) + } + + @MainActor + func testManagerLeavesRequestsCreatedBeforeAcceptanceManual() async throws { + let harness = AllowanceHarness() + let acceptedAt = "2026-09-24T11:30:00Z" + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms(), lastEventAt: acceptedAt)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertFalse(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:00:00Z"))) + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:29:40Z")), "Within the clock tolerance") + XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:45:00Z"))) + } + + // MARK: Helpers + + @MainActor + func testReceivedRequestWithoutAmountCeilingIsDropped() async throws { + let harness = AllowanceHarness() + // Valid for the SDK: an expiry alone satisfies "an amount ceiling or expiry". + let uncapped = try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: nil, + periodLimits: [], + lifetimeAmountLimit: nil, + activeFrom: nil, + expiresAt: "2100-01-01T00:00:00Z", + allowedPaymentEndpointIdentifiers: nil + ) + await harness.sdk.setRecords([Fixtures.record(state: .proposed, terms: uncapped, proposedByMe: false)]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) + await manager.activate(identity: Fixtures.identityKey) + + XCTAssertEqual(manager.entries, []) + XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: uncapped, proposedByMe: false))) + XCTAssertNotNil(PaykitAllowance(record: Fixtures.record(localRole: .allowee, terms: uncapped, proposedByMe: false))) + } + + @MainActor + func testManagerLeavesARequestWithUnknownCreationTimeManual() async throws { + let harness = AllowanceHarness() + try await harness.sdk.setRecords([Fixtures.record(terms: Fixtures.standardTerms())]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }, canPayNow: { true }) + await manager.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest(createdAt: "not-a-timestamp") + XCTAssertNil(request.createdAt) + + XCTAssertFalse(manager.coversRequest(request)) + } + + private static func waitUntil(timeout: TimeInterval = 5, _ condition: () -> Bool) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + + /// Three succeeded automatic payments this month total 49,500 sats of the 50,000 sat cap. + private static func stateNearTheMonthlyCap() throws -> Paykit.AllowanceAccountingState { + try Fixtures.accountingState(occurrences: [ + Fixtures.occurrence(requestId: "paid-1", attempts: [ + Fixtures.attemptRecord(id: "paid-1", amount: "0.0002", admittedAt: "2026-09-05T10:00:00Z", status: .succeeded), + ]), + Fixtures.occurrence(requestId: "paid-2", attempts: [ + Fixtures.attemptRecord(id: "paid-2", amount: "0.0002", admittedAt: "2026-09-12T10:00:00Z", status: .succeeded), + ]), + Fixtures.occurrence(requestId: "paid-3", attempts: [ + Fixtures.attemptRecord(id: "paid-3", amount: "0.000095", admittedAt: "2026-09-20T10:00:00Z", status: .succeeded), + ]), + ]) + } + + private static func journalEntry( + attemptId: String, + request: PaykitPaymentRequest, + stage: PaykitAllowanceLocalState.Stage, + paymentHash: String? = nil, + paymentListVersion: UInt64? = nil + ) -> PaykitAllowanceLocalState.JournalEntry { + PaykitAllowanceLocalState.JournalEntry( + attemptId: attemptId, + isAutomatic: true, + requestId: request.id, + allowanceId: Fixtures.walletAllowanceId, + amountSats: request.allowanceAmountSats, + paymentEndpointIdentifier: Fixtures.lightningIdentifier, + paymentHash: paymentHash, + onchainAddress: nil, + transactionId: nil, + stage: stage, + createdAt: Fixtures.now, + paymentListVersion: paymentListVersion, + previousPaymentListVersion: paymentListVersion.map { $0 - 1 } + ) + } +} + +// MARK: - Test doubles + +private struct AllowanceHarness { + static let paymentHash = String(repeating: "ab", count: 32) + static let invoice = "lnbcrt10u1allowancetestinvoice" + static let paymentAppId = "bitkit" + + let log = AllowanceCallLog() + let store = AllowanceMemoryStore() + let clock = AllowanceTestClock(PaykitAllowanceFixtures.now) + let sdk: AllowanceSdkMock + let payer: AllowancePayerMock + let lookup: AllowanceLightningLookupMock + let executor: PaykitAllowanceExecutor + + init(payment: PrivatePaykitAllowancePayment = Self.lightningPayment()) { + sdk = AllowanceSdkMock(log: log) + payer = AllowancePayerMock(log: log, payment: payment) + lookup = AllowanceLightningLookupMock(log: log) + let clock = clock + executor = PaykitAllowanceExecutor(sdk: sdk, store: store, payer: payer, lightningLookup: lookup, now: { clock.now() }) + } + + static func candidate(blocked: Paykit.AllowanceAccountingBlock? = nil) -> Paykit.AllowanceCandidate { + Paykit.AllowanceCandidate( + allowanceId: PaykitAllowanceFixtures.walletAllowanceId, + eligiblePaymentEndpointIdentifiers: [PaykitAllowanceFixtures.lightningIdentifier], + blocked: blocked + ) + } + + static let onchainAddress = "bcrt1qallowancetestaddress" + + static func onchainPayment() -> PrivatePaykitAllowancePayment { + PrivatePaykitAllowancePayment( + endpoint: PublicPaykitService.Endpoint( + methodId: .bitcoinOnchainP2wpkh, + value: onchainAddress, + min: nil, + max: nil, + rawPayload: "{\"value\":\"\(onchainAddress)\"}" + ), + context: PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [PaykitAllowanceFixtures.onchainIdentifier: paymentAppId], + paymentListVersion: 3 + ), + lightningPaymentHash: nil, + lightningInvoiceHasAmount: false + ) + } + + static func lightningPayment() -> PrivatePaykitAllowancePayment { + PrivatePaykitAllowancePayment( + endpoint: PublicPaykitService.Endpoint( + methodId: .bitcoinLightningBolt11, + value: invoice, + min: nil, + max: nil, + rawPayload: "{\"value\":\"\(invoice)\"}" + ), + context: PrivatePaykitPaymentContext( + paymentAppsByEndpoint: [PaykitAllowanceFixtures.lightningIdentifier: paymentAppId], + paymentListVersion: 3 + ), + lightningPaymentHash: paymentHash, + lightningInvoiceHasAmount: true + ) + } +} + +private final class AllowanceCallLog: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + var entries: [String] { + lock.withLock { storage } + } + + func append(_ entry: String) { + lock.withLock { storage.append(entry) } + } +} + +private final class AllowanceTestClock: @unchecked Sendable { + private let lock = NSLock() + private var date: Date + + init(_ date: Date) { + self.date = date + } + + func now() -> Date { + lock.withLock { date } + } + + func set(_ newDate: Date) { + lock.withLock { date = newDate } + } +} + +private final class AllowanceMemoryStore: PaykitAllowanceStoring, @unchecked Sendable { + private let lock = NSLock() + private var states: [String: PaykitAllowanceLocalState] = [:] + private var savesFail = false + + func load(identity: String) throws -> PaykitAllowanceLocalState { + lock.withLock { states[identity] ?? PaykitAllowanceLocalState() } + } + + func save(_ state: PaykitAllowanceLocalState, identity: String) throws { + try lock.withLock { + if savesFail { + throw AllowanceMockError.unsupported + } + states[identity] = state + } + } + + func failSaves() { + lock.withLock { savesFail = true } + } + + func seed(_ state: PaykitAllowanceLocalState, identity: String) { + lock.withLock { states[identity] = state } + } +} + +private final class AllowanceEventRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [PaykitAllowanceEvent] = [] + private var cancellable: AnyCancellable? + + init() { + cancellable = PaykitAllowanceExecutor.eventPublisher.sink { [weak self] event in + self?.append(event) + } + } + + var events: [PaykitAllowanceEvent] { + lock.withLock { storage } + } + + private func append(_ event: PaykitAllowanceEvent) { + lock.withLock { storage.append(event) } + } +} + +/// A latch a test holds closed to stop a mocked payment mid-send. +private final class AllowanceGate: @unchecked Sendable { + private let lock = NSLock() + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + lock.lock() + if isOpen { + lock.unlock() + continuation.resume() + } else { + waiters.append(continuation) + lock.unlock() + } + } + } + + func open() { + lock.lock() + isOpen = true + let pending = waiters + waiters = [] + lock.unlock() + pending.forEach { $0.resume() } + } +} + +private enum AllowanceMockError: Error { + case unsupported +} + +private actor AllowanceLightningLookupMock: PaykitLightningPaymentProofLookingUp { + private let log: AllowanceCallLog + private var statuses: [String: PaykitLightningPaymentProofStatus] = [:] + + init(log: AllowanceCallLog) { + self.log = log + } + + func setStatuses(_ statuses: [String: PaykitLightningPaymentProofStatus]) { + self.statuses = statuses + } + + func status(paymentHash: String) async -> PaykitLightningPaymentProofStatus { + log.append("lightningStatus") + return statuses[paymentHash.lowercased()] ?? .unknown + } +} + +private actor AllowancePayerMock: PaykitAllowancePaying { + struct PreparedProof: Equatable { + let appId: String + let endpoint: String + let allowanceId: String? + } + + struct LightningPayment: Equatable { + let bolt11: String + let sats: UInt64? + } + + private let log: AllowanceCallLog + private let payment: PrivatePaykitAllowancePayment? + private var resolveError: Error? + private var claimError: Error? + private var lightningError: Error? + private var lightningGate: AllowanceGate? + private var lightningErrorIsDefinite = true + private(set) var callCount = 0 + private(set) var claimedRequestIds: [PaykitPaymentRequest.ID] = [] + private(set) var acceptanceIntents: [PaykitPaymentRequest.ID] = [] + private(set) var paymentListOutcomes: [PrivatePaymentListSendOutcome] = [] + private(set) var preparedProofs: [PreparedProof] = [] + private(set) var associatedPaymentHashes: [String] = [] + private(set) var lightningPayments: [LightningPayment] = [] + + init(log: AllowanceCallLog, payment: PrivatePaykitAllowancePayment?) { + self.log = log + self.payment = payment + } + + private func called(_ name: String) { + callCount += 1 + log.append(name) + } + + func setResolveError(_ error: Error?) { + resolveError = error + } + + func setClaimError(_ error: Error?) { + claimError = error + } + + func setLightningError(_ error: Error?) { + lightningError = error + } + + func setLightningErrorIsDefinite(_ definite: Bool) { + lightningErrorIsDefinite = definite + } + + /// Holds `payLightning` until the returned gate opens, so a test can act while the send is in progress. + func holdLightning() -> AllowanceGate { + let gate = AllowanceGate() + lightningGate = gate + return gate + } + + func resolve(_ request: PaykitPaymentRequest, eligibleIdentifiers: [String]) async throws -> PrivatePaykitAllowancePayment? { + called("resolve") + if let resolveError { + throw resolveError + } + return payment + } + + func consumePaymentList(publicKey: String, context: PrivatePaykitPaymentContext, attemptId: UUID) async throws { + called("consumePaymentList") + } + + func resolvePaymentList( + publicKey: String, + context: PrivatePaykitPaymentContext, + attemptId: UUID, + outcome: PrivatePaymentListSendOutcome + ) async { + called("resolvePaymentList") + paymentListOutcomes.append(outcome) + } + + struct ReleasedList: Equatable { + let publicKey: String + let version: UInt64 + let previousVersion: UInt64? + } + + private(set) var releasedLists: [ReleasedList] = [] + + func previousPaymentListVersion(publicKey: String, attemptId: UUID) async -> UInt64? { + nil + } + + private var releaseGate: AllowanceGate? + + /// Holds the next list release until the returned gate opens. + func holdNextRelease() -> AllowanceGate { + let gate = AllowanceGate() + releaseGate = gate + return gate + } + + func releaseConsumedPaymentList(publicKey: String, version: UInt64, previousVersion: UInt64?) async { + called("releaseConsumedPaymentList") + releasedLists.append(ReleasedList(publicKey: publicKey, version: version, previousVersion: previousVersion)) + if let gate = releaseGate { + releaseGate = nil + await gate.wait() + } + } + + func claimForExecution(_ request: PaykitPaymentRequest) async throws { + called("claimForExecution") + if let claimError { + throw claimError + } + claimedRequestIds.append(request.id) + } + + func recordAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async throws -> Bool { + called("recordAcceptanceIntent") + guard !acceptanceIntents.contains(request.id) else { return false } + acceptanceIntents.append(request.id) + return true + } + + func seedAcceptanceIntent(_ id: PaykitPaymentRequest.ID) { + acceptanceIntents.append(id) + } + + func discardAcceptanceIntent(_ request: PaykitPaymentRequest, identity: String) async { + called("discardAcceptanceIntent") + acceptanceIntents.removeAll { $0 == request.id } + } + + func ensurePaymentAllowed(_ request: PaykitPaymentRequest) async throws { + called("ensurePaymentAllowed") + } + + func prepareProof(_ request: PaykitPaymentRequest, paymentAppId: String, paymentEndpointIdentifier: String, allowanceId: String?) async throws { + called("prepareProof") + preparedProofs.append(PreparedProof(appId: paymentAppId, endpoint: paymentEndpointIdentifier, allowanceId: allowanceId)) + } + + func associateLightningPayment(_ request: PaykitPaymentRequest, paymentHash: String) async throws { + called("associateLightningPayment") + associatedPaymentHashes.append(paymentHash) + } + + func payLightning(_ request: PaykitPaymentRequest, bolt11: String, sats: UInt64?) async throws { + called("payLightning") + lightningPayments.append(LightningPayment(bolt11: bolt11, sats: sats)) + if let lightningGate { + await lightningGate.wait() + // The real node call is cancellable, so a cancelled caller must not be able to stop the payment here. + try Task.checkCancellation() + } + if let lightningError { + throw lightningError + } + } + + func failLightningPayment(paymentHash: String, submissionError: Error) async -> Bool { + called("failLightningPayment") + return lightningErrorIsDefinite + } + + func cancelProofPreparation(_ request: PaykitPaymentRequest) async { + called("cancelProofPreparation") + } +} + +private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { + static let automaticAttemptId = "attempt-1" + static let manualAttemptId = "manual-1" + + private let log: AllowanceCallLog + struct Proposal { + let counterparty: String + let localRole: Paykit.AllowanceLocalRole + let terms: Paykit.AllowanceTerms + } + + private var records: [Paykit.AllowanceRecord] = [] + private var peers: [LinkedPeerRecord] = [] + private var accountingState: Paykit.AllowanceAccountingState? = PaykitAllowanceFixtures.accountingState() + private var candidates: [Paykit.AllowanceCandidate] = [AllowanceHarness.candidate()] + private var automaticReservation: Paykit.PaymentAttemptDecision? + private var manualReservation: Paykit.PaymentAttemptDecision? + private var beginDecision: Paykit.PaymentAttemptDecision? + private var beginError: Error? + private var acceptError: Error? + private(set) var evaluatedTrustedTimes: [String] = [] + private(set) var selections: [Paykit.AllowanceSelectionInput] = [] + private(set) var acceptedEndpointIdentifiers: [String] = [] + private(set) var reservedAssociationRevisions: [UInt64] = [] + private(set) var recordedOutcomes: [Paykit.PaymentOutcomeReport] = [] + private(set) var reconciliations: [Paykit.AllowanceAccountingReconciliation] = [] + private(set) var manualOnlyOccurrences: [Paykit.PaymentOccurrence] = [] + private(set) var proposals: [Proposal] = [] + private(set) var acceptedCounterparties: [String] = [] + + init(log: AllowanceCallLog) { + self.log = log + } + + func setRecords(_ records: [Paykit.AllowanceRecord]) { + self.records = records + } + + func setPeers(_ peers: [LinkedPeerRecord]) { + self.peers = peers + } + + func setAccountingState(_ state: Paykit.AllowanceAccountingState?) { + accountingState = state + } + + func setCandidates(_ candidates: [Paykit.AllowanceCandidate]) { + self.candidates = candidates + } + + func setAutomaticReservation(_ decision: Paykit.PaymentAttemptDecision) { + automaticReservation = decision + } + + func setManualReservation(_ decision: Paykit.PaymentAttemptDecision) { + manualReservation = decision + } + + func setBeginDecision(_ decision: Paykit.PaymentAttemptDecision) { + beginDecision = decision + } + + func setAcceptError(_ error: Error?) { + acceptError = error + } + + func setBeginError(_ error: Error?) { + beginError = error + } + + func linkedPeers() async throws -> [LinkedPeerRecord] { + log.append("linkedPeers") + return peers + } + + func listAllowances(filter: Paykit.AllowanceFilter) async throws -> [Paykit.AllowanceRecord] { + log.append("listAllowances") + return records + } + + func proposeAllowance( + counterparty: String, + localRole: Paykit.AllowanceLocalRole, + terms: Paykit.AllowanceTerms + ) async throws -> Paykit.AllowanceRecord { + log.append("proposeAllowance") + proposals.append(Proposal(counterparty: counterparty, localRole: localRole, terms: terms)) + let record = PaykitAllowanceFixtures.record( + allowanceId: "proposed-\(proposals.count)", + counterparty: counterparty, + state: .proposed, + terms: terms + ) + records.append(record) + return record + } + + func acceptAllowance(counterparty: String, allowanceId: String, expectedProposalStreamItemId: UInt64) async throws -> Paykit.AllowanceRecord { + log.append("acceptAllowance") + acceptedCounterparties.append(counterparty) + guard let record = records.first(where: { $0.counterparty == counterparty && $0.allowanceId == allowanceId }) else { + throw AllowanceMockError.unsupported + } + return record + } + + func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + log.append("rejectAllowance") + throw AllowanceMockError.unsupported + } + + func endAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { + log.append("endAllowance") + throw AllowanceMockError.unsupported + } + + func receivePrivateMessages(counterparty: String) async throws -> Paykit.PrivateStreamIntakeReport { + log.append("receivePrivateMessages") + return Paykit.PrivateStreamIntakeReport(receiveBatchId: nil, streamItemIds: [], eventConflicts: []) + } + + func processOutboundPrivateMessages(counterparty: String) async throws -> Paykit.OutboundPrivateSendReport { + log.append("processOutboundPrivateMessages") + return Paykit.OutboundPrivateSendReport(attempted: [], sent: [], failed: [], reservationCleanupFailures: [], recoveryMarkerFailures: []) + } + + func allowanceAccountingState() async throws -> Paykit.AllowanceAccountingState? { + log.append("allowanceAccountingState") + return accountingState + } + + func reconcileAllowanceAccounting(_ reconciliation: Paykit.AllowanceAccountingReconciliation) async throws -> Paykit.AllowanceAccountingState { + log.append("reconcileAllowanceAccounting") + reconciliations.append(reconciliation) + let reconciled = Paykit.AllowanceAccountingState( + revision: (reconciliation.expectedRevision ?? 0) + 1, + epoch: accountingState?.epoch ?? "epoch-1", + requiresReconciliation: false, + history: reconciliation.history + ) + accountingState = reconciled + return reconciled + } + + func evaluateAllowanceCandidates(scope: Paykit.PaymentRequestScope, trustedTime: String) async throws -> [Paykit.AllowanceCandidate] { + log.append("evaluateAllowanceCandidates") + evaluatedTrustedTimes.append(trustedTime) + return candidates + } + + func acceptPaymentRequestAutomatically( + scope: Paykit.PaymentRequestScope, + selection: Paykit.AllowanceSelectionInput, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.AllowanceAssociationRecord { + log.append("acceptPaymentRequestAutomatically") + if let acceptError { + throw acceptError + } + selections.append(selection) + acceptedEndpointIdentifiers.append(checks.paymentEndpointIdentifier) + return Paykit.AllowanceAssociationRecord( + request: PaykitAllowanceFixtures.accountingScope(scope.paymentRequestId), + revisions: [ + Paykit.AllowanceAssociationRevision( + revision: 1, + allowanceId: selection.allowanceId, + effectiveFrom: nil, + authorizationId: nil, + authorizedAt: selection.trustedTime + ), + ] + ) + } + + func reserveAutomaticPayment( + occurrence: Paykit.PaymentOccurrence, + expectedAssociationRevision: UInt64, + checks: Paykit.PaymentExecutionChecks + ) async throws -> Paykit.PaymentAttemptDecision { + log.append("reserveAutomaticPayment") + reservedAssociationRevisions.append(expectedAssociationRevision) + if let automaticReservation { + return automaticReservation + } + return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord( + id: Self.automaticAttemptId, + admittedAt: checks.trustedTime, + status: .prepared + )) + } + + func reserveManualPayment(occurrence: Paykit.PaymentOccurrence, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit + .PaymentAttemptDecision + { + log.append("reserveManualPayment") + if let manualReservation { + return manualReservation + } + return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord( + id: Self.manualAttemptId, + mode: .manual, + allowanceId: nil, + admittedAt: checks.trustedTime, + status: .prepared + )) + } + + func beginPaymentExecution(attemptId: String, checks: Paykit.PaymentExecutionChecks) async throws -> Paykit.PaymentAttemptDecision { + log.append("beginPaymentExecution") + if let beginError { + throw beginError + } + if let beginDecision { + return beginDecision + } + return try .ready(attempt: PaykitAllowanceFixtures.attemptRecord(id: attemptId, admittedAt: checks.trustedTime, status: .submitted)) + } + + func recordPaymentOutcome(_ report: Paykit.PaymentOutcomeReport) async throws -> Paykit.PaymentAttemptRecord { + log.append("recordPaymentOutcome") + recordedOutcomes.append(report) + let status: Paykit.PaymentExecutionStatus = switch report.outcome { + case .succeeded: .succeeded + case .failed: .failed + case .unknown: .unknown + } + return try PaykitAllowanceFixtures.attemptRecord(id: report.attemptId, status: status) + } + + func markPaymentManualOnly(occurrence: Paykit.PaymentOccurrence) async throws -> Paykit.PaymentOccurrenceRecord { + log.append("markPaymentManualOnly") + manualOnlyOccurrences.append(occurrence) + return Paykit.PaymentOccurrenceRecord( + key: Paykit.PaymentOccurrenceKey( + request: PaykitAllowanceFixtures.accountingScope(occurrence.request.paymentRequestId), + billingPeriod: nil + ), + disposition: .manualOnly, + allowanceId: nil, + associationRevision: nil, + attempts: [] + ) + } +} diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift new file mode 100644 index 000000000..a43372fb8 --- /dev/null +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -0,0 +1,632 @@ +@testable import Bitkit +import Foundation +import LDKNode +import Paykit +import XCTest + +final class PaykitAllowanceTests: XCTestCase { + private typealias Fixtures = PaykitAllowanceFixtures + + // MARK: Terms and records + + func testTermsCarryPerPaymentRangeAnchoredUtcMonthAndAllowlist() throws { + let monthAnchor = PaykitAllowanceTime.monthStart(containing: Fixtures.now) + let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier] + + let terms = try Fixtures.limits.terms(monthAnchor: monthAnchor, allowedPaymentEndpointIdentifiers: allowlist) + + XCTAssertEqual(terms.asset(), PaykitIssuerInterop.bitcoinAsset) + let perPayment = try XCTUnwrap(terms.perPaymentAmount()) + XCTAssertEqual(perPayment.minimum(), "0") + XCTAssertEqual(perPayment.maximum(), "0.00005") + XCTAssertEqual(terms.periodLimits().count, 1) + let monthly = try XCTUnwrap(terms.periodLimits().first) + XCTAssertEqual(monthly.amountLimit(), "0.0005") + XCTAssertNil(monthly.paymentCountLimit()) + XCTAssertEqual(monthly.period().kind(), "anchored") + XCTAssertEqual(monthly.period().every(), 1) + XCTAssertEqual(monthly.period().unit(), "month") + XCTAssertTrue(PaykitAllowance.isMonthly(monthly.period())) + XCTAssertEqual(monthly.period().anchor(), "2026-09-01T00:00:00.000Z") + XCTAssertEqual(monthly.period().anchor().flatMap(PaykitAllowanceTime.parse), Fixtures.septemberAnchor) + XCTAssertNil(terms.lifetimeAmountLimit()) + XCTAssertNil(terms.activeFrom()) + XCTAssertNil(terms.expiresAt()) + XCTAssertEqual(terms.allowedPaymentEndpointIdentifiers(), allowlist) + } + + func testRecordReadsBackSatsAnchorRoleAndAllowlist() throws { + let allowlist = [Fixtures.lightningIdentifier, Fixtures.onchainIdentifier] + let terms = try Fixtures.limits.terms(monthAnchor: Fixtures.septemberAnchor, allowedPaymentEndpointIdentifiers: allowlist) + let record = Fixtures.record(state: .proposed, terms: terms, proposedByMe: true) + + let allowance = try XCTUnwrap(PaykitAllowance(record: record)) + + XCTAssertEqual(allowance.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(allowance.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(allowance.role, .allower) + XCTAssertTrue(allowance.isAllower) + XCTAssertTrue(allowance.isProposedByMe) + XCTAssertEqual(allowance.lifecycleState, .proposed) + XCTAssertEqual(allowance.perPaymentMaxSats, 5000) + XCTAssertEqual(allowance.monthlyLimitSats, 50000) + XCTAssertEqual(allowance.monthlyAnchor, Fixtures.septemberAnchor) + XCTAssertNil(allowance.activeFrom) + XCTAssertNil(allowance.expiresAt) + XCTAssertEqual(allowance.allowedPaymentEndpointIdentifiers, allowlist) + XCTAssertEqual(allowance.lastEventAt, Fixtures.utc("2026-09-02T10:00:00Z")) + + let receivedRecord = Fixtures.record(localRole: .allowee, state: .proposed, terms: terms, proposedByMe: false) + let received = try XCTUnwrap(PaykitAllowance(record: receivedRecord)) + XCTAssertEqual(received.role, .allowee) + XCTAssertFalse(received.isAllower) + XCTAssertFalse(received.isProposedByMe) + XCTAssertTrue(received.isAnswerable) + } + + func testRecordWithoutUsableRoleOrTermsIsSkipped() throws { + let terms = try Fixtures.standardTerms() + + XCTAssertNil(PaykitAllowance(record: Fixtures.record(localRole: nil, terms: terms))) + XCTAssertNil(PaykitAllowance(record: Fixtures.record(localRole: .unknown, terms: terms))) + XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: nil))) + } + + func testStatusMapsEveryLifecycleState() throws { + func status( + _ state: Paykit.AllowanceLifecycleState, + proposedByMe: Bool = true, + terms: Paykit.AllowanceTerms? = nil, + at date: Date = PaykitAllowanceFixtures.now + ) throws -> PaykitAllowance.Status { + let resolvedTerms = try terms ?? Fixtures.standardTerms() + let record = Fixtures.record(state: state, terms: resolvedTerms, proposedByMe: proposedByMe) + return try XCTUnwrap(PaykitAllowance(record: record)).status(at: date) + } + + XCTAssertEqual(try status(.proposed, proposedByMe: true), .awaitingAnswer) + XCTAssertEqual(try status(.proposed, proposedByMe: false), .awaitingMyAnswer) + XCTAssertEqual(try status(.accepted), .active) + XCTAssertEqual(try status(.rejected), .declined) + XCTAssertEqual(try status(.ended), .ended) + XCTAssertEqual(try status(.conflicted), .conflicted) + XCTAssertEqual(try status(.unknown), .conflicted) + + let expiry = Fixtures.utc("2026-09-20T00:00:00Z") + let expiring = try Fixtures.customTerms(expiresAt: expiry) + XCTAssertEqual(try status(.accepted, terms: expiring, at: expiry.addingTimeInterval(-1)), .active) + XCTAssertEqual(try status(.accepted, terms: expiring, at: expiry), .expired) + XCTAssertEqual(try status(.accepted, terms: expiring, at: Fixtures.now), .expired) + + let start = Fixtures.utc("2026-10-01T00:00:00Z") + let scheduled = try Fixtures.customTerms(activeFrom: start) + XCTAssertEqual(try status(.accepted, terms: scheduled, at: Fixtures.now), .notYetActive) + XCTAssertEqual(try status(.accepted, terms: scheduled, at: start), .active) + } + + func testSatsFromBitcoinAmountReadsTheZeroMinimum() { + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0"), 0) + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.00000000"), 0) + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.00005"), 5000) + XCTAssertEqual(PaykitAllowance.sats(fromBitcoinAmount: "0.0005"), 50000) + XCTAssertNil(PaykitAllowance.sats(fromBitcoinAmount: "abc")) + } + + // MARK: Monthly window + + func testMonthStartUsesTheUtcCalendarMonth() { + XCTAssertEqual(PaykitAllowanceTime.monthStart(containing: Fixtures.now), Fixtures.septemberAnchor) + XCTAssertEqual(PaykitAllowanceTime.monthStart(containing: Fixtures.utc("2026-10-01T01:00:00+02:00")), Fixtures.septemberAnchor) + XCTAssertEqual( + PaykitAllowanceTime.monthStart(containing: Fixtures.utc("2026-09-30T23:30:00-02:00")), + Fixtures.utc("2026-10-01T00:00:00Z") + ) + } + + func testMonthlyWindowFromFirstOfMonthAnchor() { + let anchor = Fixtures.septemberAnchor + let cases: [(date: String, start: String, end: String)] = [ + ("2026-09-01T00:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + ("2026-09-24T12:00:00Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + ("2026-09-30T23:59:59Z", "2026-09-01T00:00:00Z", "2026-10-01T00:00:00Z"), + ("2026-10-01T00:00:00Z", "2026-10-01T00:00:00Z", "2026-11-01T00:00:00Z"), + ("2026-12-31T23:59:59Z", "2026-12-01T00:00:00Z", "2027-01-01T00:00:00Z"), + ("2027-02-10T08:00:00Z", "2027-02-01T00:00:00Z", "2027-03-01T00:00:00Z"), + ("2026-08-31T23:59:59Z", "2026-08-01T00:00:00Z", "2026-09-01T00:00:00Z"), + ("2026-07-15T12:00:00Z", "2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"), + ] + + for testCase in cases { + let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc(testCase.date)) + XCTAssertEqual(window.start, Fixtures.utc(testCase.start), "start for \(testCase.date)") + XCTAssertEqual(window.end, Fixtures.utc(testCase.end), "end for \(testCase.date)") + } + } + + func testMonthlyWindowClampsJanuaryThirtyFirstAnchorInFebruary() { + let anchor = Fixtures.utc("2026-01-31T12:30:00Z") + + let midFebruary = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc("2026-02-15T00:00:00Z")) + XCTAssertEqual(midFebruary.start, anchor) + XCTAssertEqual(midFebruary.end, Fixtures.utc("2026-02-28T12:30:00Z")) + + let lateFebruary = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc("2026-02-28T12:30:00Z")) + XCTAssertEqual(lateFebruary.start, Fixtures.utc("2026-02-28T12:30:00Z")) + } + + /// Vectors from paykit-lib `test_anchored_months_clamp_from_original_anchor`: every boundary is counted from the + /// original anchor, so the window after a clamped February still ends on March 31. + func testMonthlyWindowAfterClampedFebruaryMatchesPaykitAnchoredArithmetic() { + let anchor = Fixtures.utc("2026-01-31T12:30:00Z") + let vectors: [(date: String, start: String, end: String)] = [ + ("2026-02-28T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + ("2026-03-30T12:30:00Z", "2026-02-28T12:30:00Z", "2026-03-31T12:30:00Z"), + ("2025-12-01T00:00:00Z", "2025-11-30T12:30:00Z", "2025-12-31T12:30:00Z"), + ] + let marchAnchor = Fixtures.utc("2026-03-31T00:00:00Z") + let beforeMarchAnchor = PaykitAllowanceTime.monthlyWindow(anchor: marchAnchor, containing: Fixtures.utc("2026-01-15T00:00:00Z")) + let allowance = Fixtures.allowance(monthlyAnchor: anchor) + let lateMarchAttempt = PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, + allowanceId: allowance.allowanceId, + amountSats: 50000, + admittedAt: Fixtures.utc("2026-03-29T09:00:00Z"), + isLive: true + ) + + for vector in vectors { + let window = PaykitAllowanceTime.monthlyWindow(anchor: anchor, containing: Fixtures.utc(vector.date)) + XCTAssertEqual(window.start, Fixtures.utc(vector.start), "start for \(vector.date)") + XCTAssertEqual(window.end, Fixtures.utc(vector.end), "end for \(vector.date)") + } + XCTAssertEqual(beforeMarchAnchor.start, Fixtures.utc("2025-12-31T00:00:00Z"), "start before a March 31 anchor") + XCTAssertEqual(beforeMarchAnchor.end, Fixtures.utc("2026-01-31T00:00:00Z"), "end before a March 31 anchor") + XCTAssertFalse( + PaykitAllowanceCapacity.fits( + amountSats: 1000, + allowance: allowance, + attempts: [lateMarchAttempt], + now: Fixtures.utc("2026-03-30T12:30:00Z") + ), + "A March 29 attempt at the cap must count on March 30" + ) + } + + // MARK: Capacity + + func testCapacityFitsUnderTheCap() { + let attempts = [Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z")] + + XCTAssertEqual(Fixtures.usedSats(attempts), 20000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityFitsExactlyAtTheCap() { + let attempts = [ + Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z"), + Fixtures.capacityAttempt(sats: 25000, at: "2026-09-12T10:00:00Z"), + ] + + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityRejectsOverTheMonthlyCap() { + let attempts = [ + Fixtures.capacityAttempt(sats: 20000, at: "2026-09-05T10:00:00Z"), + Fixtures.capacityAttempt(sats: 25000, at: "2026-09-12T10:00:00Z"), + ] + + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5001, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityRejectsOverThePerPaymentMaximum() { + let allowance = Fixtures.allowance() + + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: allowance, attempts: [], now: Fixtures.now)) + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 5001, allowance: allowance, attempts: [], now: Fixtures.now)) + } + + func testCapacityIgnoresFailedAttempts() { + let attempts = [ + Fixtures.capacityAttempt(sats: 45000, at: "2026-09-05T10:00:00Z", isLive: false), + Fixtures.capacityAttempt(sats: 10000, at: "2026-09-12T10:00:00Z"), + ] + + XCTAssertEqual(Fixtures.usedSats(attempts), 10000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testCapacityIgnoresPreviousMonthAndOtherAllowanceAttempts() { + let attempts = [ + Fixtures.capacityAttempt(sats: 50000, at: "2026-08-31T23:59:59Z"), + Fixtures.capacityAttempt(allowanceId: Fixtures.otherAllowanceId, sats: 50000, at: "2026-09-10T10:00:00Z"), + Fixtures.capacityAttempt(sats: 1000, at: "2026-09-01T00:00:00Z"), + ] + + XCTAssertEqual(Fixtures.usedSats(attempts), 1000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testAnotherContactsAttemptsUnderTheSameIdNeverFillThisContactsCap() { + let attempts = [ + Fixtures.capacityAttempt(counterparty: Fixtures.otherCounterpartyKey, sats: 45000, at: "2026-09-05T10:00:00Z"), + Fixtures.capacityAttempt(sats: 1000, at: "2026-09-06T10:00:00Z"), + ] + + XCTAssertEqual(Fixtures.usedSats(attempts), 1000) + XCTAssertTrue(PaykitAllowanceCapacity.fits(amountSats: 5000, allowance: Fixtures.allowance(), attempts: attempts, now: Fixtures.now)) + } + + func testAllowerTermsWithADustPerPaymentMaximumAreDropped() throws { + let terms = try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: Paykit.AllowanceAmountRange(minimum: "0", maximum: "0.000000001"), + periodLimits: [ + Paykit.AllowancePeriodLimit( + amountLimit: "0.0005", + paymentCountLimit: nil, + period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "month", anchor: "2026-09-01T00:00:00Z") + ), + ], + lifetimeAmountLimit: nil, + activeFrom: nil, + expiresAt: nil, + allowedPaymentEndpointIdentifiers: [Fixtures.lightningIdentifier] + ) + + XCTAssertNil(PaykitAllowance(record: Fixtures.record(terms: terms, proposedByMe: false))) + XCTAssertNotNil(PaykitAllowance(record: Fixtures.record(localRole: .allowee, terms: terms, proposedByMe: false))) + } + + func testAutomaticLightningRoutingFeeIsCappedAtOnePercent() { + XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1000).maxTotalRoutingFeeMsat, 10000) + XCTAssertEqual(PaykitAllowanceLivePayer.routeParameters(amountSats: 1).maxTotalRoutingFeeMsat, 10) + } + + func testCapacityWithoutBothCeilingsNeverFits() { + XCTAssertFalse(PaykitAllowanceCapacity.fits( + amountSats: 5000, + allowance: Fixtures.allowance(monthlyLimitSats: nil), + attempts: [], + now: Fixtures.now + )) + XCTAssertFalse(PaykitAllowanceCapacity.fits( + amountSats: 5000, + allowance: Fixtures.allowance(perPaymentMaxSats: nil), + attempts: [], + now: Fixtures.now + )) + XCTAssertFalse(PaykitAllowanceCapacity.fits( + amountSats: 5000, + allowance: Fixtures.allowance(monthlyAnchor: nil), + attempts: [], + now: Fixtures.now + )) + } + + func testAttemptsFromHistoryKeepAutomaticAllowanceAttempts() throws { + let history = try Paykit.AllowanceAccountingHistory( + associations: [], + occurrences: [ + Fixtures.occurrence(requestId: "req-1", attempts: [ + Fixtures.attemptRecord(id: "failed", amount: "0.0001", admittedAt: "2026-09-02T10:00:00Z", status: .failed), + Fixtures.attemptRecord(id: "paid", amount: "0.0001", admittedAt: "2026-09-03T10:00:00Z", status: .succeeded), + ]), + Fixtures.occurrence(requestId: "req-2", attempts: [ + Fixtures.attemptRecord(id: "manual", mode: .manual, allowanceId: nil, admittedAt: "2026-09-04T10:00:00Z", status: .succeeded), + Fixtures.attemptRecord(id: "unattributed", allowanceId: nil, admittedAt: "2026-09-04T11:00:00Z", status: .succeeded), + Fixtures.attemptRecord(id: "open", amount: "0.00002", admittedAt: "2026-09-05T10:00:00Z", status: .submitted), + ]), + ], + watermarks: [] + ) + + let attempts = PaykitAllowanceCapacity.attempts(from: history) + + XCTAssertEqual(attempts, [ + PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, + allowanceId: Fixtures.walletAllowanceId, + amountSats: 10000, + admittedAt: Fixtures.utc("2026-09-02T10:00:00Z"), + isLive: false + ), + PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, + allowanceId: Fixtures.walletAllowanceId, + amountSats: 10000, + admittedAt: Fixtures.utc("2026-09-03T10:00:00Z"), + isLive: true + ), + PaykitAllowanceCapacity.Attempt( + counterparty: Fixtures.counterpartyKey, + allowanceId: Fixtures.walletAllowanceId, + amountSats: 2000, + admittedAt: Fixtures.utc("2026-09-05T10:00:00Z"), + isLive: true + ), + ]) + } + + // MARK: Trusted time vs subscription clock offset + + func testStatusAndCapacityUseInjectedTimeWhileSubscriptionClockIsOffset() throws { + snapshotAppDefaults(SubscriptionClock.offsetDaysKey) + UserDefaults.standard.set(365, forKey: SubscriptionClock.offsetDaysKey) + try XCTSkipUnless(SubscriptionClock.offsetDays() == 365, "The subscription clock offset is unavailable in this build") + let realNow = Date() + XCTAssertGreaterThan(SubscriptionClock.subscriptionNow(), realNow.addingTimeInterval(364 * 24 * 60 * 60)) + + let allowance = Fixtures.allowance(expiresAt: Fixtures.now.addingTimeInterval(30 * 24 * 60 * 60)) + XCTAssertEqual(allowance.status(at: Fixtures.now), .active) + + let attempts = [Fixtures.capacityAttempt(sats: 50000, at: "2026-09-10T10:00:00Z")] + XCTAssertEqual(Fixtures.usedSats(attempts), 50000) + XCTAssertFalse(PaykitAllowanceCapacity.fits(amountSats: 1, allowance: allowance, attempts: attempts, now: Fixtures.now)) + } + + // MARK: Grouping + + func testEntryPrimaryIsTheGrantsAllowance() { + let allowance = Fixtures.allowance(allowanceId: Fixtures.walletAllowanceId) + + let entry = PaykitAllowanceEntry(id: "group", allowances: [allowance], limits: Fixtures.limits) + + XCTAssertEqual(entry.primary.allowanceId, Fixtures.walletAllowanceId) + XCTAssertEqual(entry.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(entry.perPaymentMaxSats, 5000) + XCTAssertEqual(entry.monthlyLimitSats, 50000) + XCTAssertEqual(entry.limits, Fixtures.limits) + } +} + +/// Shared builders for the Allowance suites. +enum PaykitAllowanceFixtures { + static let identityKey = "pubky\(String(repeating: "z", count: 52))" + static let counterpartyKey = "pubky\(String(repeating: "y", count: 52))" + static let otherCounterpartyKey = "pubky\(String(repeating: "x", count: 52))" + static let walletAllowanceId = "allowance-wallet" + static let otherAllowanceId = "allowance-other" + static let lightningIdentifier = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue + static let onchainIdentifier = PublicPaykitService.MethodId.bitcoinOnchainP2wpkh.rawValue + static let now = utc("2026-09-24T12:00:00Z") + static let septemberAnchor = utc("2026-09-01T00:00:00Z") + static let limits = PaykitAllowanceLimits(perPaymentUsd: 5, monthlyUsd: 50, perPaymentSats: 5000, monthlySats: 50000) + + static func utc(_ value: String) -> Date { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: value) else { + preconditionFailure("Invalid fixture timestamp \(value)") + } + return date + } + + static func standardTerms() throws -> Paykit.AllowanceTerms { + try limits.terms(monthAnchor: septemberAnchor, allowedPaymentEndpointIdentifiers: [lightningIdentifier]) + } + + static func customTerms(activeFrom: Date? = nil, expiresAt: Date? = nil) throws -> Paykit.AllowanceTerms { + try Paykit.AllowanceTerms( + asset: PaykitIssuerInterop.bitcoinAsset, + perPaymentAmount: Paykit.AllowanceAmountRange(minimum: "0", maximum: "0.00005"), + periodLimits: [ + Paykit.AllowancePeriodLimit( + amountLimit: "0.0005", + paymentCountLimit: nil, + period: Paykit.AllowancePeriod(kind: "anchored", every: 1, unit: "month", anchor: "2026-09-01T00:00:00Z") + ), + ], + lifetimeAmountLimit: nil, + activeFrom: activeFrom.map(PaykitAllowanceTime.format), + expiresAt: expiresAt.map(PaykitAllowanceTime.format), + allowedPaymentEndpointIdentifiers: [lightningIdentifier] + ) + } + + static func record( + allowanceId: String = walletAllowanceId, + counterparty: String = counterpartyKey, + localRole: Paykit.AllowanceLocalRole? = .allower, + state: Paykit.AllowanceLifecycleState = .accepted, + historyStatus: Paykit.AllowanceHistoryStatus = .consistent, + terms: Paykit.AllowanceTerms?, + proposedByMe: Bool = true, + lastEventAt: String? = "2026-09-02T10:00:00Z" + ) -> Paykit.AllowanceRecord { + Paykit.AllowanceRecord( + counterparty: counterparty, + allowanceId: allowanceId, + localRole: localRole, + state: state, + historyStatus: historyStatus, + proposalEventId: "proposal-\(allowanceId)", + terms: terms, + proposalStreamItemId: proposedByMe ? nil : 1, + proposalOutboundMessageId: proposedByMe ? 1 : nil, + proposalOutboundStatus: nil, + acceptanceEventId: nil, + acceptanceOutboundStatus: nil, + rejectionEventId: nil, + rejectionOutboundStatus: nil, + endEventId: nil, + endOutboundStatus: nil, + pendingCausalEventIds: [], + conflictEventIds: [], + lastStreamItemId: nil, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: lastEventAt, + invalidReason: nil + ) + } + + static func contact(publicKey: String = counterpartyKey) -> PubkyContact { + PubkyContact( + publicKey: publicKey, + profile: PubkyProfile(publicKey: publicKey, name: "Alice", bio: "", imageUrl: nil, links: [], status: nil) + ) + } + + static func linkedPeer(counterparty: String = counterpartyKey, state: Paykit.LinkedPeerState) -> Paykit.LinkedPeerRecord { + Paykit.LinkedPeerRecord( + counterparty: counterparty, + state: state, + lastSyncAt: nil, + lastPrivateReceiveAt: nil, + failureCount: 0, + localRecoveryAttemptId: nil, + localRecoveryMarkerCreatedAt: nil, + localRecoveryMarkerLastError: nil, + remoteRecoveryAttemptId: nil, + remoteRecoveryMarkerObservedAt: nil + ) + } + + static func allowance( + allowanceId: String = walletAllowanceId, + counterparty: String = counterpartyKey, + role: PaykitAllowance.Role = .allower, + state: Paykit.AllowanceLifecycleState = .accepted, + perPaymentMaxSats: UInt64? = 5000, + monthlyLimitSats: UInt64? = 50000, + monthlyAnchor: Date? = septemberAnchor, + expiresAt: Date? = nil + ) -> PaykitAllowance { + PaykitAllowance( + id: PaykitAllowance.ID(counterparty: counterparty, allowanceId: allowanceId), + role: role, + lifecycleState: state, + isProposedByMe: role == .allower, + perPaymentMaxSats: perPaymentMaxSats, + monthlyLimitSats: monthlyLimitSats, + monthlyAnchor: monthlyAnchor, + expiresAt: expiresAt, + allowedPaymentEndpointIdentifiers: [lightningIdentifier] + ) + } + + static func capacityAttempt( + allowanceId: String = walletAllowanceId, + counterparty: String = counterpartyKey, + sats: UInt64, + at timestamp: String, + isLive: Bool = true + ) -> PaykitAllowanceCapacity.Attempt { + PaykitAllowanceCapacity.Attempt( + counterparty: counterparty, allowanceId: allowanceId, amountSats: sats, admittedAt: utc(timestamp), isLive: isLive + ) + } + + static func usedSats(_ attempts: [PaykitAllowanceCapacity.Attempt]) -> UInt64 { + PaykitAllowanceCapacity.usedSats( + counterparty: counterpartyKey, allowanceId: walletAllowanceId, attempts: attempts, anchor: septemberAnchor, now: now + ) + } + + static func attemptRecord( + id: String, + mode: Paykit.PaymentExecutionMode = .automatic, + allowanceId: String? = walletAllowanceId, + amount: String = "0.00001", + admittedAt: String = "2026-09-24T12:00:00Z", + status: Paykit.PaymentExecutionStatus, + epoch: String = "epoch-1" + ) throws -> Paykit.PaymentAttemptRecord { + try Paykit.PaymentAttemptRecord( + attemptId: id, + mode: mode, + allowanceId: allowanceId, + associationRevision: allowanceId == nil ? nil : 1, + amount: Paykit.AccountingAmount(value: amount, asset: PaykitIssuerInterop.bitcoinAsset), + admittedAt: admittedAt, + status: status, + epoch: epoch + ) + } + + static func accountingScope(_ paymentRequestId: String) -> Paykit.PaymentAccountingScope { + Paykit.PaymentAccountingScope( + localPublicKey: identityKey, + counterparty: counterpartyKey, + paymentRequestId: paymentRequestId + ) + } + + static func occurrence( + requestId: String, + attempts: [Paykit.PaymentAttemptRecord], + allowanceId: String? = walletAllowanceId + ) -> Paykit.PaymentOccurrenceRecord { + Paykit.PaymentOccurrenceRecord( + key: Paykit.PaymentOccurrenceKey(request: accountingScope(requestId), billingPeriod: nil), + disposition: .automatic, + allowanceId: allowanceId, + associationRevision: allowanceId == nil ? nil : 1, + attempts: attempts + ) + } + + static func accountingState( + revision: UInt64 = 1, + epoch: String = "epoch-1", + requiresReconciliation: Bool = false, + occurrences: [Paykit.PaymentOccurrenceRecord] = [] + ) -> Paykit.AllowanceAccountingState { + Paykit.AllowanceAccountingState( + revision: revision, + epoch: epoch, + requiresReconciliation: requiresReconciliation, + history: Paykit.AllowanceAccountingHistory(associations: [], occurrences: occurrences, watermarks: []) + ) + } + + static func paymentRequest( + id: String = "550e8400-e29b-41d4-a716-446655440001", + counterparty: String = counterpartyKey, + amount: String = "0.00001", + asset: String = PaykitIssuerInterop.bitcoinAsset, + conversion: Paykit.PaymentConversion? = nil, + createdAt: String = "2026-09-24T11:00:00Z" + ) throws -> PaykitPaymentRequest { + let record = try Paykit.PaymentRequestRecord( + counterparty: counterparty, + paymentRequestId: id, + localRole: .payer, + state: .proposed, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "650e8400-e29b-41d4-a716-446655440000", + proposalAppId: "bitkit", + payerAppId: nil, + executionClaimAppId: nil, + terms: Paykit.PaymentRequestTerms( + amount: Paykit.PaymentRequestAmount(value: amount, asset: asset), + paymentReference: Paykit.PaymentReference(text: "invoice-123"), + proposalExpiresAt: nil, + recurrence: nil, + acceptedPaymentEndpointIdentifiers: [lightningIdentifier], + paymentEndpoints: nil, + requiredAppId: "bitkit", + conversion: conversion, + paymentDeadline: nil, + metadata: Paykit.PrivateJsonObject(text: "{}") + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + conversionQuotes: [], + paymentProofs: [], + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: createdAt, + invalidReason: nil + ) + return try XCTUnwrap(PaykitPaymentRequest(record: record, now: now, network: .regtest)) + } +} diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 8b6ef1e67..2cccd9828 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -3401,7 +3401,7 @@ actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { billingPeriod: proof.billingPeriod, paymentAppId: proof.paymentAppId, paymentEndpointIdentifier: proof.paymentEndpointIdentifier, - allowanceId: nil, + allowanceId: proof.allowanceId, conversionQuoteId: nil, proof: proof.proof, recordedAt: "2027-01-15T08:01:00Z" diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift index be6476257..27311740d 100644 --- a/BitkitTests/PaykitPaymentStateBackupTests.swift +++ b/BitkitTests/PaykitPaymentStateBackupTests.swift @@ -557,6 +557,28 @@ final class PaykitPaymentStateBackupTests: XCTestCase { } } + func testAutomaticAllowancePaymentProofKeepsItsAllowanceIdThroughBackup() throws { + let proof = PendingPaykitPaymentProof( + identity: identity, + requestId: PaykitPaymentRequest.ID(paymentRequestId: "allowance-request", counterparty: identity), + paymentAppId: "bitkit", + paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + allowanceId: "allowance-1", + paymentStarted: true, + paymentIdentifier: "payment-hash", + proofData: nil + ) + let encoded = try JSONEncoder().encode(PaykitPaymentStateBackup.Proof(proof)) + let restored = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: encoded).restored() + XCTAssertEqual(restored.allowanceId, "allowance-1") + + var legacy = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + legacy.removeValue(forKey: "allowanceId") + let legacyProof = try JSONDecoder().decode(PaykitPaymentStateBackup.Proof.self, from: JSONSerialization.data(withJSONObject: legacy)) + XCTAssertNil(try legacyProof.restored().allowanceId, "Backups written before allowances still restore") + } + func testHardwareBackupRequiresCompleteSignedReceipt() throws { let envelope = try JSONDecoder().decode(WalletBackupV1.self, from: Self.completeAttemptGolden()) let proof = try XCTUnwrap(envelope.paykitPaymentState?.pendingProofs.first) diff --git a/changelog.d/next/799.added.md b/changelog.d/next/799.added.md new file mode 100644 index 000000000..6e89aeb68 --- /dev/null +++ b/changelog.d/next/799.added.md @@ -0,0 +1 @@ +Allowances: set a per-payment and a monthly limit for a Paykit contact so their payment requests within the limits are paid automatically. diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md new file mode 100644 index 000000000..6f79efde2 --- /dev/null +++ b/journeys/allowances/README.md @@ -0,0 +1,57 @@ +# Allowances journeys + +Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a +contact, the payee accepts it, requests within the limits are paid without asking, a request above a +limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey +pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. +The PIN journey checks that the payer's grant, accept and end ask for the payment PIN when the +user requires one, while automatic payments never prompt. + +## Setup + +Run Bitkit against regtest with Paykit UI enabled on two instances that have each other saved as +contacts and linked, exactly as for [`../subscriptions/README.md`](../subscriptions/README.md). One +instance plays the payer (the one that sets the allowance), the other the payee (the one that sends +requests). Automatic payments pay only the payee's private Lightning invoice; a request the payee +can take only on-chain asks as an ordinary Payment Request. The payer needs a spending balance +above 50,000 sats with a usable channel, and the payee needs receiving capacity; fund both through +the staging LSP. + +Allow notifications for Bitkit on the payer when it asks: the "Payment Executed" and "Limit +Reached" events post a local notification when they can, and fall back to an in-app toast that the +UI snapshot cannot see. + +The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey +sets $5 a payment and $10 a month, the first monthly stop. Requests are one-time Payment Requests +created from the Payments tab of the payee, in the fiat unit. Dollar amounts on screen are converted +at the current rate, so a sats amount next to them will differ between runs. + +## Reference evidence + +The source run drove every journey on 2026-09-24 across two Android 16 emulators against the +staging regtest LSP, with Paykit built from pubky/paykit-rs#161, and the set, accept, auto-pay, +ask-above-limit and end flows again on two iOS simulators. A $2 and a $4 request were paid +about two seconds after arriving, a $20 request asked, the third $4 request on a $10 monthly cap +raised Limit Reached, ending the allowance from either side stopped automatic payments, and a payer +killed right after handing the payment to the node never paid twice after relaunch. + +## Identifiers used + +- Tab: `Tab-allowances`; empty state `AllowancesEmpty`; footer button `AllowanceAdd` +- Contact picker: `AllowanceContact-` +- Set sheet: `SetAllowance`, sliders `AllowancePerPayment` and `AllowanceMonthly` with stops + `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, + `AllowanceSave` +- List row: `AllowanceRow-` with its status line `AllowanceRowStatus`; the entry id is the payer's grant id, or the contact key and allowance id on the payee +- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, + `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` +- Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` +- Payments tab: `Tab-payments`, `PaymentRequestRequestPayment`, + `PaymentRequestRow--one-time` whose subtitle ends with "· Auto-paid" for an + automatic payment +- Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` + +The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is +needed to reach it. The file names, journey names and step prose match +[`bitkit-android/journeys/allowances`](https://github.com/synonymdev/bitkit-android/tree/master/journeys/allowances); +only the identifier annotations and the kill, relaunch and notification mechanics differ. diff --git a/journeys/allowances/above-limit-asks.xml b/journeys/allowances/above-limit-asks.xml new file mode 100644 index 000000000..a24e7ff87 --- /dev/null +++ b/journeys/allowances/above-limit-asks.xml @@ -0,0 +1,19 @@ + + + A request above the per-payment limit is never paid automatically: it arrives as an ordinary + Payment Request that the payer pays by swiping, and a manual payment does not count toward the + amount paid automatically. Requires the Active $5 a payment allowance from set-and-accept.xml + and a payer balance above the request. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active and at least one automatic payment made, per auto-pay-under-limit.xml + On the payee instance, open Subscriptions, the Payments tab (id "Tab-payments"), tap Request Payment (id "PaymentRequestRequestPayment") and send the payer a one-time Payment Request for $20 with the note "Artpack" + Switch to the payer instance + Verify the Payment Request sheet (id "PaymentRequestsSheet") opens for $20.00 from the payee, or the bell (id "PaymentRequestsBell") shows one pending request, and that nothing was sent automatically + Verify no "Payment Executed" notification was posted for this request + Pay it from the sheet (id "PaymentRequestPay-<paymentRequestId>") and complete Swipe To Pay + Verify Bitcoin Sent shows about $20.00 + Open Subscriptions, tap the Payments tab and verify the "Artpack" row is listed without "· Auto-paid" in its subtitle + Tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") still shows only the automatic payments, not the $20 + + diff --git a/journeys/allowances/auto-pay-under-limit.xml b/journeys/allowances/auto-pay-under-limit.xml new file mode 100644 index 000000000..1a293c59b --- /dev/null +++ b/journeys/allowances/auto-pay-under-limit.xml @@ -0,0 +1,22 @@ + + + A request within both limits is paid without the payer seeing a sheet. The payer only gets a + "Payment Executed" notification, the payee receives the payment, and the payer's Payments tab and + allowance detail both account for it. Requires the Active allowance from set-and-accept.xml and + notification permission granted on the payer. + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance from set-and-accept.xml Active, and the payer's notification permission granted + On the payee instance, open the drawer menu, tap Subscriptions, tap the Payments tab (id "Tab-payments") and tap Request Payment (id "PaymentRequestRequestPayment") + Send the payer a one-time Payment Request for $2 with the note "Devlog" + Switch to the payer instance, with Bitkit in the foreground on the home screen + Verify that within about five seconds no Payment Request sheet opens and the bell (id "PaymentRequestsBell") shows no pending request + Verify a "Payment Executed" notification with the text "Auto-pay sent $2.00 to <payee>" is posted as a banner, or under Bitkit in Notification Center; with notifications not allowed it is an in-app toast + Switch to the payee instance and verify the payment arrives, either as the Received Instant Bitcoin sheet or as a $2.00 "Received from <payer>" row in Activity + Switch to the payer instance, open the drawer menu, tap Subscriptions and tap the Payments tab + Verify the "Devlog" row (id "PaymentRequestRow-<paymentRequestId>-one-time") is listed with a subtitle ending in "· Auto-paid" + Tap the Allowances tab, then tap the payee's row + Verify the detail sheet (id "AllowanceDetail") shows PAID AUTOMATICALLY "$2.00" (id "AllowancePaidSoFar") and the explanation "Requests within these limits are paid automatically. Anything above them asks you first." (id "AllowanceDetailStatus") + Open Activity from the home screen and verify the newest row reads "Sent to <payee>" for $2.00 + + diff --git a/journeys/allowances/end-stops-auto-pay.xml b/journeys/allowances/end-stops-auto-pay.xml new file mode 100644 index 000000000..f3003d79f --- /dev/null +++ b/journeys/allowances/end-stops-auto-pay.xml @@ -0,0 +1,26 @@ + + + Either side can end an allowance from its detail sheet, and from then on every request asks + again. The first half ends it on the payer; the second half sets a fresh allowance and ends it + on the payee. In both cases the payer's row keeps the amount that was paid automatically, and the + next request waits in the bell as an ordinary Payment Request. + + + Launch the E2E Bitkit app on both instances with an Active allowance that has paid at least one request automatically, per auto-pay-under-limit.xml + On the payer instance, open the drawer menu, tap Subscriptions, tap the Allowances tab and tap the payee's row + Verify the detail sheet (id "AllowanceDetail") ends with a swipe control reading "Swipe To End Allowance" + Swipe the control to the end + Verify the sheet dismisses and the row's status line (id "AllowanceRowStatus") reads "Ended · " followed by the amount paid automatically, such as "Ended · $2.00 paid automatically", with the row dimmed + Tap the row and verify the detail explanation (id "AllowanceDetailStatus") reads "This allowance has ended. Every request asks again." with no swipe control + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterEnd" + On the payer instance, verify it is not paid: no "Payment Executed" notification, and the request waits in the bell (id "PaymentRequestsBell") as an ordinary Payment Request + Dismiss that request + On the payee instance, open Subscriptions and the Allowances tab, and verify the payer's row reads "Ended" + Set and accept a fresh $5 a payment, $50 a month allowance between the same two instances, per set-and-accept.xml + On the payee instance, tap the Active row, verify the detail ends with "Swipe To End Allowance", and swipe it to the end + Verify the payee's row reads "Ended" + On the payer instance, verify its row for that allowance reads "Ended · $0.00 paid automatically" + On the payee instance, send the payer a one-time Payment Request for $2 with the note "AfterPayeeEnd" + On the payer instance, verify it is not paid and waits in the bell as an ordinary Payment Request, then dismiss it + + diff --git a/journeys/allowances/monthly-cap-reached.xml b/journeys/allowances/monthly-cap-reached.xml new file mode 100644 index 000000000..1b32ebdd3 --- /dev/null +++ b/journeys/allowances/monthly-cap-reached.xml @@ -0,0 +1,22 @@ + + + Requests keep paying themselves until the month's allowance is used up; the first request that + would exceed it is left to the payer with a "Limit Reached" notification and the ordinary Payment + Request sheet. The journey uses a $5 a payment, $10 a month allowance so two $4 requests fit and + the third does not. The second request can take a few extra seconds: after a payment the payee + publishes a new private payment list, and the payer waits for it rather than asking. + + + Launch the E2E Bitkit app on both instances with no Active allowance between them and the payer's notification permission granted + On the payer instance, set an allowance for the payee as in set-and-accept.xml, but with the $5 stop (id "AllowancePerPaymentStop-1") and the $10 stop (id "AllowanceMonthlyStop-0"), and have the payee accept it + Verify the payer's row reads "Active · $5 a payment" with "$ 10.00" over "Monthly limit" + On the payee instance, send the payer a one-time Payment Request for $4 with the note "Cap1" + On the payer instance, verify it is paid without a sheet and a "Payment Executed" notification reads "Auto-pay sent $4.00 to <payee>" + On the payee instance, wait for the payment to arrive, then send a second $4 request with the note "Cap2" + On the payer instance, verify it is paid without a sheet within about fifteen seconds, and a second "Payment Executed" notification is posted + On the payee instance, send a third $4 request with the note "Cap3" + On the payer instance, verify a "Limit Reached" notification reads "A $4.00 request from <payee> is above your allowance. Review it to pay." and the Payment Request sheet opens for $4.00 + Dismiss the request (id "PaymentRequestDismiss-<paymentRequestId>") + Open Subscriptions, tap the Allowances tab, tap the payee's row and verify PAID AUTOMATICALLY (id "AllowancePaidSoFar") reads "$8.00" + + diff --git a/journeys/allowances/pin-gates-allowance-changes.xml b/journeys/allowances/pin-gates-allowance-changes.xml new file mode 100644 index 000000000..087725b8d --- /dev/null +++ b/journeys/allowances/pin-gates-allowance-changes.xml @@ -0,0 +1,29 @@ + + + When the payer requires a PIN for payments, setting an allowance and ending it ask for the same + PIN (or biometrics) as Send before anything changes, and a cancelled check changes nothing. The + payee's answers never ask. Automatic payments under an accepted allowance never ask: the checked + grant is their authorisation. + + Precondition: the two-instance setup in README.md, with no allowance between them yet. On the + payer, enable a PIN you choose under Settings ▸ Security ▸ PIN Code and turn on "Also require for + payments"; no biometrics enrolled on the simulator, so the PIN pad shows directly. Never enter a + wrong PIN more than once. + + iOS difference: the PIN pad is Send's PIN screen and opens as a sheet over the allowance sheet; + "press back" means its back button. + + + On the payer instance, open Subscriptions, tap the Allowances tab, tap Add (id "AllowanceAdd"), pick the payee and keep $5 a payment and $50 a month + Tap Save Allowance (id "AllowanceSave") + Verify the PIN pad shows over the sheet, then press back + Verify the Set sheet (id "SetAllowance") is still showing with the same payee and limits, and the payee instance gets no review sheet + Tap Save Allowance again and enter the PIN + Verify the sheet dismisses and the payer's row reads "Waiting for an answer" + On the payee instance, verify the review sheet (id "AllowanceReview") opens on its own, tap Accept (id "AllowanceAccept") and verify no PIN is asked + On the payee instance, send the payer a one-time Payment Request for $2 and verify the payer pays it automatically with no PIN pad, per auto-pay-under-limit.xml + On the payer instance, tap the payee's row and swipe "Swipe To End Allowance" to the end + Verify the PIN pad shows, press back, and verify the detail sheet (id "AllowanceDetail") is back with the swipe control at its start and the allowance still Active + Swipe it to the end again, enter the PIN, and verify the row reads "Ended · $2.00 paid automatically" + + diff --git a/journeys/allowances/restart-never-pays-twice.xml b/journeys/allowances/restart-never-pays-twice.xml new file mode 100644 index 000000000..e991ca742 --- /dev/null +++ b/journeys/allowances/restart-never-pays-twice.xml @@ -0,0 +1,21 @@ + + + Killing the payer while an automatic payment is in flight must never lead to a second payment + after relaunch. The app records the request as taken before it hands the payment to the node, so + on relaunch it either sees the node finish the first attempt or hands the request back to the + payer as an ordinary Payment Request; it never starts a new automatic attempt. The kill has to + land within about two seconds of the request arriving, which the app log marks with "Handed an + allowance payment to the node". + + + Launch the E2E Bitkit app on both instances with the $5 a payment, $50 a month allowance Active, per set-and-accept.xml + On the payer instance, start tailing the simulator log (`xcrun simctl spawn <udid> log stream --predicate 'subsystem == "to.bitkit"'`) for "Handed an allowance payment to the node" so the kill can follow it at once + On the payee instance, send the payer a one-time Payment Request for $2 with the note "Devlog3" + As soon as the log line appears on the payer, run `xcrun simctl terminate <udid> to.bitkit` + Relaunch the payer with `xcrun simctl launch <udid> to.bitkit` and wait for the node to come back up + Verify no "Payment Executed" notification is posted for a second attempt after the relaunch + Open the drawer menu, tap Subscriptions and tap the Payments tab; verify "Devlog3" is listed exactly once + On the payee instance, verify at most one $2.00 payment arrives for "Devlog3" + If the kill landed before the node took the payment: on the payer, verify "Devlog3" comes back as an ordinary Payment Request in the bell (id "PaymentRequestsBell") rather than being paid automatically, and dismiss it + + diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml new file mode 100644 index 000000000..e6b7e9c68 --- /dev/null +++ b/journeys/allowances/set-and-accept.xml @@ -0,0 +1,26 @@ + + + The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer + open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an + answer"; after it, both sides show the allowance as Active. The other allowance journeys start + from the Active state this one ends in. + + + Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel + On the payer instance, open the drawer menu and tap Subscriptions + Tap the Allowances tab (id "Tab-allowances") + Verify the empty state (id "AllowancesEmpty") reads "Set up allowances" over the group illustration, with the footer button "Add Allowance" + Tap Add Allowance (id "AllowanceAdd") + Verify the Choose Contact sheet lists the payee and tap it (id "AllowanceContact-<displayName>") + Verify the Set Allowance sheet (id "SetAllowance") shows the payee's card, a PAYMENT LIMIT slider (id "AllowancePerPayment") and a MONTHLY ALLOWANCE slider (id "AllowanceMonthly") + Tap the $5 stop of the payment limit slider (id "AllowancePerPaymentStop-1") and the $50 stop of the monthly slider (id "AllowanceMonthlyStop-1") + Verify the summary (id "AllowanceSummary") reads "Requests up to $5 will be paid automatically, up to $50 a month, without asking you each time." + Tap Save Allowance (id "AllowanceSave") + Verify the sheet dismisses and the list shows one row for the payee (id "AllowanceRow-<entryId>") whose status line (id "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right + Switch to the payee instance and bring Bitkit to the foreground + Verify the Allowance review sheet (id "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (id "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (id "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (id "AllowanceMonthlyValue") + Tap Accept (id "AllowanceAccept") + Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit + Switch back to the payer instance and verify its row now reads "Active · $5 a payment" + +