Files

162 lines
6.8 KiB
Swift

import Foundation
import SwiftUI
// MARK: - StripePoller
// Polls Stripe API every 30s for balance + recent charges.
// On new charge: slides payments list (newest first), then animates balance count-up.
final class StripePoller: @unchecked Sendable {
static let shared = StripePoller()
private var timer: DispatchSourceTimer?
private var lastChargeId: String = ""
private init() {}
func start() {
guard timer == nil else { return }
let t = DispatchSource.makeTimerSource(queue: .global(qos: .background))
t.schedule(deadline: .now() + 6, repeating: 30)
t.setEventHandler { [weak self] in self?.poll() }
t.resume()
timer = t
}
// MARK: - Poll
func pollNow() { poll() }
private func poll() {
guard let key = KeychainStore.shared.get("stripe-api-key") else { return }
fetchBalance(key: key)
fetchCharges(key: key)
}
// MARK: - Balance
private func fetchBalance(key: String) {
guard let url = URL(string: "https://api.stripe.com/v1/balance") else { return }
var req = URLRequest(url: url, timeoutInterval: 10)
// Stripe primary auth: Basic with key as username, empty password
let creds = Data("\(key):".utf8).base64EncodedString()
req.setValue("Basic \(creds)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: req) { data, response, error in
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
if code != 200 {
let errMsg: String
if code == 401 { errMsg = "Неверный API-ключ (401)" }
else if code == 403 { errMsg = "Нужен секретный ключ (sk_live_…, не pk_live_…)" }
else if code == 0 { errMsg = error?.localizedDescription ?? "Нет соединения" }
else { errMsg = "Ошибка API \(code)" }
DispatchQueue.main.async { AppState.shared.stripeError = errMsg }
return
}
guard let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
// Sum available + pending so recent charges show up immediately
let available = json["available"] as? [[String: Any]] ?? []
let pending = json["pending"] as? [[String: Any]] ?? []
let allBuckets = available + pending
guard let currency = (allBuckets.first)?["currency"] as? String else { return }
let amount = allBuckets.compactMap { $0["amount"] as? Int }.reduce(0, +)
DispatchQueue.main.async {
let state = AppState.shared
state.stripeError = nil
state.stripeCurrency = currency
state.stripeBalance = amount
// Always sync display balance if not yet showing a real value
// (charges may have set stripeLoaded=true before balance arrived)
if state.stripeDisplayBalance == 0 {
state.stripeDisplayBalance = amount
}
state.stripeLoaded = true
}
}.resume()
}
// MARK: - Charges
private func fetchCharges(key: String) {
guard let url = URL(string: "https://api.stripe.com/v1/charges?limit=3") else { return }
var req = URLRequest(url: url, timeoutInterval: 10)
let creds = Data("\(key):".utf8).base64EncodedString()
req.setValue("Basic \(creds)", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: req) { [weak self] data, response, _ in
guard let self else { return }
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
guard let data, code == 200 else { return }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let rawList = json["data"] as? [[String: Any]] else { return }
let parsed = rawList.compactMap { self.parseCharge($0) }
DispatchQueue.main.async { self.handleCharges(parsed) }
}.resume()
}
private func parseCharge(_ c: [String: Any]) -> StripePayment? {
guard let id = c["id"] as? String,
let amount = c["amount"] as? Int,
let currency = c["currency"] as? String,
let status = c["status"] as? String else { return nil }
let ts = (c["created"] as? TimeInterval) ?? 0
let desc = c["description"] as? String
?? (c["billing_details"] as? [String: Any]).flatMap { $0["name"] as? String }
return StripePayment(id: id, amount: amount, currency: currency,
description: desc, createdAt: Date(timeIntervalSince1970: ts),
status: status)
}
@MainActor
private func handleCharges(_ payments: [StripePayment]) {
let state = AppState.shared
// First load — populate silently, anchor lastChargeId to avoid duplicate on next poll
if state.stripePayments.isEmpty {
state.stripePayments = payments
lastChargeId = payments.first?.id ?? ""
// Sync display balance (balance may have already arrived; use it)
state.stripeDisplayBalance = state.stripeBalance
state.stripeLoaded = true
return
}
guard let newest = payments.first, newest.id != lastChargeId else { return }
lastChargeId = newest.id
guard let idx = state.tasks.firstIndex(where: { $0.id == "integration_stripe" }) else { return }
let focused = state.focusId == "integration_stripe"
// 1. Slide payments: new enters top, max 3 shown
withAnimation(.spring(response: 0.38, dampingFraction: 0.82)) {
state.stripePayments = Array(([newest] + state.stripePayments).prefix(3))
}
state.tasks[idx].state = .finished
state.tasks[idx].steps = [newest.description ?? newest.amountFormatted]
if !focused { state.tasks[idx].pillBadge = .finished }
SoundEngine.shared.play("finish")
// 2. After slide settles, count up balance
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeOut(duration: 1.2)) {
state.stripeDisplayBalance = state.stripeBalance
}
}
// 3. Auto-clear task state after 60s
DispatchQueue.main.asyncAfter(deadline: .now() + 60) {
guard let i = state.tasks.firstIndex(where: { $0.id == "integration_stripe" }) else { return }
guard state.tasks[i].state == .finished else { return }
state.tasks[i].state = .idle
state.tasks[i].steps = []
state.tasks[i].pillBadge = nil
}
}
}