Add experimental Swift-native Sure Insights app (#3134)

* Add Swift-native Sure app

* Fix push subscriptions schema for CI

* Address native app review feedback

* Address remaining native app review feedback

* Use Flutter app logo for native icon

* Honor insight notification preferences and locale

---------

Co-authored-by: Juan Jose Mata <2v8shcb6pz@privaterelay.appleid.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
This commit is contained in:
Juan José Mata
2026-08-25 04:16:24 +02:00
committed by GitHub
co-authored by Juan Jose Mata sure-admin
parent 311f06e404
commit 0aa43de10a
51 changed files with 2633 additions and 4 deletions
+7
View File
@@ -86,6 +86,13 @@ LANGFUSE_HOST = https://cloud.langfuse.com
# Set to `true` to get error messages rendered in the /chats UI
AI_DEBUG_MODE =
# Apple Push Notification service (AI insight notifications)
# APNS_PRIVATE_KEY_BASE64 is the base64-encoded contents of the Apple .p8 key.
APNS_KEY_ID=
APNS_TEAM_ID=
APNS_BUNDLE_ID=
APNS_PRIVATE_KEY_BASE64=
# =============================================================================
# SSL/TLS Configuration for Self-Signed Certificates
# =============================================================================
+1
View File
@@ -59,6 +59,7 @@ gem "image_processing", ">= 1.2"
gem "ostruct"
gem "bcrypt", "~> 3.1"
gem "jwt"
gem "apnotic", "~> 1.8"
gem "jbuilder"
gem "countries"
+8
View File
@@ -92,6 +92,10 @@ GEM
cgi
connection_pool
standardwebhooks
apnotic (1.8.0)
base64
connection_pool (>= 2, < 4)
net-http2 (>= 0.18.3, < 2)
ast (2.4.3)
attr_required (1.0.2)
auth-sanitizer (0.2.3)
@@ -302,6 +306,7 @@ GEM
turbo-rails (>= 1.2)
htmlbeautifier (1.4.3)
htmlentities (4.3.4)
http-2 (1.2.1)
httparty (0.24.0)
csv
mini_mime (>= 1.0.0)
@@ -422,6 +427,8 @@ GEM
mutex_m (0.3.0)
net-http (0.9.1)
uri (>= 0.11.1)
net-http2 (0.19.0)
http-2 (>= 1.0)
net-imap (0.6.4.1)
date
net-protocol
@@ -869,6 +876,7 @@ DEPENDENCIES
activerecord-import
after_commit_everywhere (~> 1.0)
anthropic (~> 1.0)
apnotic (~> 1.8)
aws-sdk-s3 (~> 1.208.0)
bcrypt (~> 3.1)
benchmark-ips
+46
View File
@@ -0,0 +1,46 @@
{
"name": "Sure",
"options": {
"deploymentTarget": {
"iOS": "18.0"
}
},
"settings": {
"base": {
"SWIFT_VERSION": "6.0"
}
},
"targets": {
"Sure": {
"type": "application",
"platform": "iOS",
"sources": [
"bitrig/App"
],
"settings": {
"base": {
"PRODUCT_BUNDLE_IDENTIFIER": "app.bitrig.new.81790e0e362141ce8bc1315a7a361749",
"TARGETED_DEVICE_FAMILY": "1,2",
"ASSETCATALOG_COMPILER_APPICON_NAME": "AppIcon",
"ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME": "AccentColor"
}
},
"info": {
"path": "bitrig/App/Info.plist",
"properties": {
"CFBundleDisplayName": "Sure",
"UILaunchScreen": {},
"UIBackgroundModes": [
"remote-notification"
]
}
},
"entitlements": {
"path": "bitrig/App/Sure.entitlements",
"properties": {
"aps-environment": "development"
}
}
}
}
}
@@ -0,0 +1,40 @@
# frozen_string_literal: true
class Api::V1::InsightsController < Api::V1::BaseController
before_action :ensure_read_scope
before_action :require_preview_features_for_api
def index
insights = current_resource_owner.family.insights.visible.ordered
render json: {
insights: insights.map { |insight| serialize(insight) }
}
end
private
def ensure_read_scope
authorize_scope!(:read)
end
def require_preview_features_for_api
return if current_resource_owner.preview_features_enabled?
render_json(
{ error: "feature_disabled", message: "Preview features are not enabled for this user" },
status: :forbidden
)
end
def serialize(insight)
{
id: insight.id,
type: insight.insight_type,
title: insight.title,
body: insight.body,
priority: insight.priority,
status: insight.status,
generated_at: insight.generated_at&.iso8601
}
end
end
@@ -0,0 +1,57 @@
# frozen_string_literal: true
class Api::V1::PushSubscriptionsController < Api::V1::BaseController
before_action :ensure_write_scope
def create
token = subscription_params[:token].to_s.downcase
subscription = current_resource_owner.push_subscriptions.find_or_initialize_by(token: token)
subscription.assign_attributes(
environment: subscription_params[:environment],
platform: subscription_params[:platform],
last_registered_at: Time.current
)
subscription.save!
render json: serialize(subscription), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { error: "validation_error", message: e.record.errors.full_messages.to_sentence },
status: :unprocessable_entity
rescue ActiveRecord::RecordNotUnique
subscription = current_resource_owner.push_subscriptions.find_by(token: token)
if subscription
subscription.update!(
environment: subscription_params[:environment],
platform: subscription_params[:platform],
last_registered_at: Time.current
)
render json: serialize(subscription), status: :created
else
render json: { error: "validation_error", message: "Device token is already registered" },
status: :unprocessable_entity
end
end
def destroy
current_resource_owner.push_subscriptions.find(params[:id]).destroy!
head :no_content
end
private
def ensure_write_scope
authorize_scope!(:write)
end
def subscription_params
params.permit(:token, :environment, :platform)
end
def serialize(subscription)
{
id: subscription.id,
environment: subscription.environment,
platform: subscription.platform,
last_registered_at: subscription.last_registered_at.iso8601
}
end
end
@@ -0,0 +1,59 @@
# frozen_string_literal: true
class DeliverInsightNotificationJob < ApplicationJob
queue_as :scheduled
retry_on StandardError, wait: :polynomially_longer, attempts: 5
discard_on ActiveRecord::RecordNotFound
def self.enqueue_for(insight)
return unless Apns::Client.configured?
insight.family.users.includes(:push_subscriptions).find_each do |user|
next unless user.preview_features_enabled?
user.push_subscriptions.recent.find_each do |subscription|
perform_later(insight_id: insight.id, push_subscription_id: subscription.id)
end
end
end
def perform(insight_id:, push_subscription_id:)
insight = Insight.find(insight_id)
subscription = PushSubscription.find(push_subscription_id)
return unless subscription.user.family_id == insight.family_id
response = I18n.with_locale(insight.family.locale) do
Apns::Client.new(environment: subscription.environment).deliver(
token: subscription.token,
title: I18n.t("insights.notification.title"),
body: I18n.t("insights.notification.body"),
insight_id: insight.id
)
end
return if response.ok?
if invalid_token_response?(response)
subscription.destroy!
return
end
raise "APNs rejected notification with status #{response.status}: #{response.body.inspect}"
rescue => e
DebugLogEntry.capture(
category: "insights",
level: "error",
message: "Failed to deliver insight notification: #{e.class}: #{e.message}",
source: "DeliverInsightNotificationJob",
family: insight&.family,
metadata: { insight_id: insight_id, push_subscription_id: push_subscription_id }
)
raise
end
private
def invalid_token_response?(response)
response.status == "410" ||
(response.status == "400" && response.body.is_a?(Hash) && response.body["reason"] == "BadDeviceToken")
end
end
+10 -4
View File
@@ -37,11 +37,12 @@ class GenerateInsightsJob < ApplicationJob
# broadcast below too, not just the generation.
return unless family.preview_features_enabled?
with_advisory_lock(family_id) do
notifiable_insights = with_advisory_lock(family_id) do
I18n.with_locale(family.locale) do
result = Insight::GeneratorRegistry.new(family).generate_all
upsert_insights(family, result.insights)
created_or_resurfaced = upsert_insights(family, result.insights)
expire_stale_insights(family, result)
created_or_resurfaced
end
end
@@ -49,6 +50,7 @@ class GenerateInsightsJob < ApplicationJob
# current state, so a subscribed /insights page (waiting on its manual
# refresh) always gets its list and button restored.
broadcast_feed(family)
Array(notifiable_insights).each { |insight| DeliverInsightNotificationJob.enqueue_for(insight) }
end
def broadcast_feed(family)
@@ -84,7 +86,7 @@ class GenerateInsightsJob < ApplicationJob
def upsert_insights(family, generated_insights)
writer = Insight::BodyWriter.new(family)
generated_insights.each do |generated|
generated_insights.filter_map do |generated|
metadata = normalize_json(generated.metadata)
facts = normalize_json(generated.facts)
existing = family.insights.find_by(dedup_key: generated.dedup_key)
@@ -120,11 +122,13 @@ class GenerateInsightsJob < ApplicationJob
read_at: nil,
dismissed_at: nil
)
existing
elsif existing.expired?
# The condition cleared earlier and has now returned with the same
# numbers. Expiry was the system's doing, not the user's, so the
# insight resurfaces; the body is still accurate, so no rewrite.
existing.update!(status: "active", facts: facts, generated_at: Time.current, read_at: nil)
existing
else
# Same signal, same numbers: don't rewrite the body (avoids an LLM
# call) and don't undo the user's read/dismissed state. Facts still
@@ -132,10 +136,11 @@ class GenerateInsightsJob < ApplicationJob
# keeping them current is exactly why they're not part of the
# material-change comparison.
existing.update!(facts: facts, generated_at: Time.current)
nil
end
rescue ActiveRecord::RecordNotUnique
# A concurrent run created the same dedup_key first; it owns this row.
next
nil
rescue => e
DebugLogEntry.capture(
category: "insights",
@@ -145,6 +150,7 @@ class GenerateInsightsJob < ApplicationJob
family: family,
metadata: { dedup_key: generated.dedup_key, insight_type: generated.insight_type }
)
nil
end
end
+14
View File
@@ -0,0 +1,14 @@
class PushSubscription < ApplicationRecord
belongs_to :user
enum :environment, { sandbox: "sandbox", production: "production" }, validate: true
normalizes :token, with: ->(token) { token.downcase }
validates :token, presence: true, uniqueness: { case_sensitive: false },
format: { with: /\A[0-9a-f]{64,200}\z/i }
validates :platform, inclusion: { in: %w[ios] }
validates :last_registered_at, presence: true
scope :recent, -> { where("last_registered_at > ?", 90.days.ago) }
end
+1
View File
@@ -25,6 +25,7 @@ class User < ApplicationRecord
has_many :sessions, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :api_keys, dependent: :destroy
has_many :push_subscriptions, dependent: :destroy
has_many :webauthn_credentials, dependent: :destroy
has_many :mobile_devices, dependent: :destroy
has_many :invitations, foreign_key: :inviter_id, dependent: :destroy
+50
View File
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require "apnotic"
require "base64"
require "stringio"
module Apns
class Client
REQUIRED_ENV_KEYS = %w[APNS_KEY_ID APNS_TEAM_ID APNS_BUNDLE_ID APNS_PRIVATE_KEY_BASE64].freeze
def self.configured?
REQUIRED_ENV_KEYS.all? { |key| ENV[key].present? }
end
def initialize(environment:)
@environment = environment.to_sym
end
def deliver(token:, title:, body:, insight_id:)
raise "APNs credentials are not configured" unless self.class.configured?
connection = build_connection
notification = Apnotic::Notification.new(token)
notification.alert = { title: title, body: body }
notification.sound = "default"
notification.topic = ENV.fetch("APNS_BUNDLE_ID")
notification.push_type = "alert"
notification.apns_collapse_id = "insight-#{insight_id}"
notification.custom_payload = { insight_id: insight_id, destination: "insights" }
connection.push(notification).tap do |response|
raise "APNs request timed out" unless response
end
ensure
connection&.close
end
private
def build_connection
options = {
auth_method: :token,
cert_path: StringIO.new(Base64.strict_decode64(ENV.fetch("APNS_PRIVATE_KEY_BASE64"))),
key_id: ENV.fetch("APNS_KEY_ID"),
team_id: ENV.fetch("APNS_TEAM_ID")
}
@environment == :sandbox ? Apnotic::Connection.development(options) : Apnotic::Connection.new(options)
end
end
end
+109
View File
@@ -0,0 +1,109 @@
import Foundation
actor SureAPIClient {
private var baseURL: URL
private var apiKey: String
private var session: URLSession
private var decoder: JSONDecoder
init(baseURL: URL, apiKey: String) {
self.baseURL = baseURL
self.apiKey = apiKey
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.waitsForConnectivity = true
session = URLSession(configuration: configuration)
decoder = JSONDecoder()
}
func update(baseURL: URL, apiKey: String) {
self.baseURL = baseURL
self.apiKey = apiKey
}
func get<T: Decodable & Sendable>(_ path: String, as type: T.Type = T.self) async throws -> T {
try await request(path: path, method: "GET", body: Optional<String>.none, as: type)
}
func post<Body: Encodable & Sendable, Response: Decodable & Sendable>(
_ path: String,
body: Body,
as type: Response.Type = Response.self
) async throws -> Response {
try await request(path: path, method: "POST", body: body, as: type)
}
func postWithoutResponse<Body: Encodable & Sendable>(_ path: String, body: Body) async throws {
let _: EmptyResponse = try await request(path: path, method: "POST", body: body, as: EmptyResponse.self)
}
func delete(_ path: String) async throws {
let _: EmptyResponse = try await request(
path: path,
method: "DELETE",
body: Optional<String>.none,
as: EmptyResponse.self
)
}
private func request<Body: Encodable & Sendable, Response: Decodable & Sendable>(
path: String,
method: String,
body: Body?,
as type: Response.Type
) async throws -> Response {
let cleanedPath = path.hasPrefix("/") ? String(path.dropFirst()) : path
guard let url = URL(string: cleanedPath, relativeTo: baseURL)?.absoluteURL else {
throw SureAPIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(apiKey, forHTTPHeaderField: "X-Api-Key")
if let body {
request.httpBody = try JSONEncoder().encode(body)
}
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw SureAPIError.invalidResponse
}
guard 200..<300 ~= httpResponse.statusCode else {
let serverError = try? decoder.decode(ServerError.self, from: data)
throw SureAPIError.server(status: httpResponse.statusCode, message: serverError?.message ?? serverError?.error)
}
if Response.self == EmptyResponse.self, data.isEmpty {
return EmptyResponse() as! Response
}
do {
return try decoder.decode(Response.self, from: data)
} catch {
throw SureAPIError.decoding(error.localizedDescription)
}
}
}
struct EmptyResponse: Codable, Sendable {}
struct ServerError: Codable, Sendable {
var error: String?
var message: String?
}
enum SureAPIError: LocalizedError, Equatable {
case invalidURL
case invalidResponse
case server(status: Int, message: String?)
case decoding(String)
var errorDescription: String? {
switch self {
case .invalidURL: "The server address is invalid."
case .invalidResponse: "The server returned an invalid response."
case let .server(status, message):
message ?? "The server returned an error (\(status))."
case let .decoding(message): "Sure returned data this app could not read: \(message)"
}
}
}
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.850",
"green" : "0.250",
"red" : "0.290"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+49
View File
@@ -0,0 +1,49 @@
import Foundation
import Security
enum KeychainStore {
private static var service: String { "am.sure.native" }
static func saveAPIKey(_ value: String) throws {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "api-key"
]
SecItemDelete(query as CFDictionary)
var attributes = query
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else { throw KeychainError(status: status) }
}
static func readAPIKey() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "api-key",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func deleteAPIKey() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "api-key"
]
SecItemDelete(query as CFDictionary)
}
}
struct KeychainError: LocalizedError {
var status: OSStatus
var errorDescription: String? { "Could not securely save the API key (\(status))." }
}
+184
View File
@@ -0,0 +1,184 @@
import Foundation
struct MoneyValue: Codable, Sendable {
var amount: String
var currency: String
var decimalAmount: Decimal {
Decimal(string: amount) ?? 0
}
var formatted: String {
decimalAmount.formatted(.currency(code: currency).precision(.fractionLength(0...2)))
}
}
struct BalanceSheet: Codable, Sendable {
var currency: String
var netWorth: MoneyValue
var assets: MoneyValue
var liabilities: MoneyValue
enum CodingKeys: String, CodingKey {
case currency
case netWorth = "net_worth"
case assets
case liabilities
}
}
struct SureAccount: Codable, Identifiable, Sendable {
var id: String
var name: String
var balance: String
var balanceCents: Int
var cashBalance: String
var cashBalanceCents: Int
var currency: String
var classification: String
var accountType: String?
var subtype: String?
var status: String
var institutionName: String?
enum CodingKeys: String, CodingKey {
case id, name, balance, currency, classification, subtype, status
case balanceCents = "balance_cents"
case cashBalance = "cash_balance"
case cashBalanceCents = "cash_balance_cents"
case accountType = "account_type"
case institutionName = "institution_name"
}
var isAsset: Bool { classification == "asset" }
}
struct AccountCollection: Codable, Sendable {
var accounts: [SureAccount]
var pagination: SurePagination?
}
struct SureBudget: Codable, Identifiable, Sendable {
var id: String
var name: String
var currency: String
var current: Bool
var budgetedSpending: String?
var allocatedSpending: String
var startDate: String
var endDate: String
enum CodingKeys: String, CodingKey {
case id, name, currency, current
case budgetedSpending = "budgeted_spending"
case allocatedSpending = "allocated_spending"
case startDate = "start_date"
case endDate = "end_date"
}
}
struct BudgetCollection: Codable, Sendable {
var budgets: [SureBudget]
var pagination: SurePagination?
}
struct SureInsight: Codable, Identifiable, Sendable, Equatable {
var id: String
var type: String
var title: String
var body: String
var priority: String
var status: String
var generatedAt: String?
enum CodingKeys: String, CodingKey {
case id, type, title, body, priority, status
case generatedAt = "generated_at"
}
var symbol: String {
switch type {
case "cash_flow_warning", "budget_at_risk": "exclamationmark.triangle.fill"
case "net_worth_milestone", "budget_on_track": "sparkles"
case "subscription_audit": "repeat"
case "idle_cash": "banknote.fill"
default: "chart.line.uptrend.xyaxis"
}
}
}
struct InsightCollection: Codable, Sendable {
var insights: [SureInsight]
}
struct SureChat: Codable, Identifiable, Sendable, Hashable {
var id: String
var title: String
var error: String?
var createdAt: String
var updatedAt: String
var lastMessageAt: String?
var messageCount: Int?
enum CodingKeys: String, CodingKey {
case id, title, error
case createdAt = "created_at"
case updatedAt = "updated_at"
case lastMessageAt = "last_message_at"
case messageCount = "message_count"
}
}
struct ChatCollection: Codable, Sendable {
var chats: [SureChat]
var pagination: SurePagination?
}
struct SurePagination: Codable, Sendable {
var page: Int
var perPage: Int
var totalCount: Int
var totalPages: Int
enum CodingKeys: String, CodingKey {
case page
case perPage = "per_page"
case totalCount = "total_count"
case totalPages = "total_pages"
}
}
struct SureMessage: Codable, Identifiable, Sendable, Equatable {
var id: String
var type: String
var role: String
var content: String
var createdAt: String
enum CodingKeys: String, CodingKey {
case id, type, role, content
case createdAt = "created_at"
}
var isUser: Bool { role == "user" || type == "user_message" }
}
struct ChatDetail: Codable, Sendable {
var id: String
var title: String
var error: String?
var createdAt: String
var updatedAt: String
var messages: [SureMessage]
var pagination: SurePagination?
enum CodingKeys: String, CodingKey {
case id, title, error, messages, pagination
case createdAt = "created_at"
case updatedAt = "updated_at"
}
var chat: SureChat {
SureChat(id: id, title: title, error: error, createdAt: createdAt, updatedAt: updatedAt)
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+74
View File
@@ -0,0 +1,74 @@
import SwiftUI
struct SureAccountsView: View {
@Environment(SureStore.self) private var store
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(store.accounts) { account in
SureAccountRow(account: account)
.padding(.horizontal)
}
if store.accounts.isEmpty && !store.isLoading {
ContentUnavailableView(
"No accounts",
systemImage: "building.columns",
description: Text("Add or link an account in Sure, then pull to refresh.")
)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.refreshable { await store.refreshAll() }
.navigationTitle("Accounts")
}
}
}
struct SureAccountRow: View {
var account: SureAccount
var body: some View {
HStack(spacing: 14) {
Image(systemName: symbol)
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 42, height: 42)
.background(Color.sureIndigo.opacity(0.1), in: .circle)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(account.name)
.font(.headline)
Text(account.institutionName ?? accountTypeLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Text(account.balance)
.font(.headline.monospacedDigit())
}
.padding(14)
.background(.background.secondary, in: .rect(cornerRadius: 16))
.accessibilityElement(children: .combine)
}
private var symbol: String {
switch account.accountType {
case "investment": "chart.line.uptrend.xyaxis"
case "credit_card": "creditcard.fill"
case "property": "house.fill"
case "vehicle": "car.fill"
case "loan": "doc.text.fill"
default: account.isAsset ? "banknote.fill" : "arrow.down.right"
}
}
private var accountTypeLabel: String {
(account.subtype ?? account.accountType ?? account.classification)
.replacingOccurrences(of: "_", with: " ")
.capitalized
}
}
+75
View File
@@ -0,0 +1,75 @@
import SwiftUI
import UserNotifications
@main
struct SureApp: App {
@UIApplicationDelegateAdaptor(SureAppDelegate.self) private var appDelegate
@State private var store = SureStore()
var body: some Scene {
WindowGroup {
SureRootView()
.environment(store)
.tint(Color.sureIndigo)
.task {
await store.restoreSession()
}
.onReceive(NotificationCenter.default.publisher(for: .sureDeviceTokenChanged)) { notification in
guard let token = notification.object as? String else { return }
Task { await store.registerPushToken(token) }
}
}
}
}
final class SureAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
Task {
let settings = await UNUserNotificationCenter.current().notificationSettings()
if settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional {
await MainActor.run { application.registerForRemoteNotifications() }
}
}
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
UserDefaults.standard.set(token, forKey: "sure.apnsDeviceToken")
NotificationCenter.default.post(name: .sureDeviceTokenChanged, object: token)
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .sound, .badge])
}
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
NotificationCenter.default.post(name: .sureOpenInsights, object: nil)
completionHandler()
}
}
extension Notification.Name {
static var sureDeviceTokenChanged: Notification.Name { Notification.Name("sureDeviceTokenChanged") }
static var sureOpenInsights: Notification.Name { Notification.Name("sureOpenInsights") }
}
extension Color {
static var sureIndigo: Color { Color(red: 0.29, green: 0.25, blue: 0.85) }
static var sureMint: Color { Color(red: 0.22, green: 0.73, blue: 0.58) }
}
+170
View File
@@ -0,0 +1,170 @@
import SwiftUI
struct SureAssistantView: View {
@Environment(SureStore.self) private var store
@State private var draft = ""
@FocusState private var composerFocused: Bool
var body: some View {
NavigationStack {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 14) {
if store.messages.isEmpty {
VStack(spacing: 16) {
Image(systemName: "sparkles")
.font(.system(size: 46))
.foregroundStyle(.tint)
Text("Ask about your money")
.font(.title2.bold())
Text("Sures Assistant can analyze your live accounts, spending, budgets, investments, and insights.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
VStack(spacing: 10) {
suggestion("What should I pay attention to this month?")
suggestion("How diversified are my investments?")
suggestion("Find easy ways I could cut costs")
}
}
.frame(maxWidth: 560)
.padding(.horizontal, 24)
.padding(.top, 52)
} else {
ForEach(store.messages) { message in
SureMessageBubble(message: message)
.id(message.id)
}
if store.isAssistantThinking {
HStack(spacing: 8) {
ProgressView()
Text("Thinking…")
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.id("thinking")
}
}
}
.frame(maxWidth: .infinity)
.padding(.vertical)
}
.onChange(of: store.messages.count) {
if let id = store.messages.last?.id {
withAnimation(.smooth) { proxy.scrollTo(id, anchor: .bottom) }
}
}
.onChange(of: store.isAssistantThinking) {
if store.isAssistantThinking {
withAnimation(.smooth) { proxy.scrollTo("thinking", anchor: .bottom) }
}
}
}
.safeAreaInset(edge: .bottom) {
composer
}
.navigationTitle(store.selectedChat?.title ?? "Assistant")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Picker("Conversation", selection: chatSelection) {
Text("New conversation").tag(Optional<String>.none)
ForEach(store.chats) { chat in
Text(chat.title).tag(Optional(chat.id))
}
}
.pickerStyle(.menu)
.labelsHidden()
Button("New conversation", systemImage: "square.and.pencil") {
store.newChat()
}
.labelStyle(.iconOnly)
}
}
}
}
private var composer: some View {
HStack(alignment: .bottom, spacing: 10) {
TextField("Message Sure Assistant", text: $draft, axis: .vertical)
.lineLimit(1...5)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(.background.secondary, in: .rect(cornerRadius: 18))
.focused($composerFocused)
.submitLabel(.send)
.onSubmit(send)
Button(action: send) {
Image(systemName: "arrow.up")
.font(.headline)
.foregroundStyle(.white)
.frame(width: 44, height: 44)
.background(Color.sureIndigo, in: .circle)
}
.accessibilityLabel("Send message")
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.isAssistantThinking)
}
.padding(.horizontal)
.padding(.vertical, 10)
.background(.bar)
}
private var chatSelection: Binding<String?> {
Binding {
store.selectedChat?.id
} set: { id in
guard let id, let chat = store.chats.first(where: { $0.id == id }) else {
store.newChat()
return
}
Task { await store.loadChat(chat) }
}
}
private func suggestion(_ text: String) -> some View {
Button {
draft = text
send()
} label: {
HStack {
Text(text)
.multilineTextAlignment(.leading)
Spacer()
Image(systemName: "arrow.up.right")
}
.padding(14)
.background(Color.sureIndigo.opacity(0.08), in: .rect(cornerRadius: 14))
}
.buttonStyle(.plain)
}
private func send() {
let message = draft
guard !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
draft = ""
Task { await store.sendMessage(message) }
}
}
struct SureMessageBubble: View {
var message: SureMessage
var body: some View {
HStack {
if message.isUser { Spacer(minLength: 44) }
Text(LocalizedStringKey(message.content))
.textSelection(.enabled)
.padding(14)
.foregroundStyle(message.isUser ? Color.white : Color.primary)
.background(
message.isUser ? AnyShapeStyle(Color.sureIndigo) : AnyShapeStyle(.background.secondary),
in: .rect(cornerRadius: 18)
)
if !message.isUser { Spacer(minLength: 44) }
}
.padding(.horizontal)
.accessibilityElement(children: .combine)
.accessibilityLabel(message.isUser ? "You: \(message.content)" : "Sure Assistant: \(message.content)")
}
}
+52
View File
@@ -0,0 +1,52 @@
import SwiftUI
struct SureBudgetsView: View {
@Environment(SureStore.self) private var store
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: 14) {
ForEach(store.budgets) { budget in
VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(budget.name)
.font(.headline)
Text("\(budget.startDate) \(budget.endDate)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if budget.current {
Text("CURRENT")
.font(.caption2.bold())
.foregroundStyle(Color.sureMint)
}
}
Divider()
LabeledContent("Allocated", value: budget.allocatedSpending)
if let planned = budget.budgetedSpending {
LabeledContent("Planned", value: planned)
}
}
.padding(18)
.background(.background.secondary, in: .rect(cornerRadius: 18))
.accessibilityElement(children: .combine)
}
if store.budgets.isEmpty && !store.isLoading {
ContentUnavailableView(
"No budgets yet",
systemImage: "chart.pie",
description: Text("Create a budget in Sure to track it here.")
)
}
}
.frame(maxWidth: .infinity)
.padding()
}
.refreshable { await store.refreshAll() }
.navigationTitle("Budgets")
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import SwiftUI
struct SureMainTabView: View {
@Environment(SureStore.self) private var store
var body: some View {
@Bindable var store = store
TabView(selection: $store.selectedTab) {
SureOverviewView()
.tabItem { Label("Overview", systemImage: "square.grid.2x2.fill") }
.tag(SureTab.overview)
SureAccountsView()
.tabItem { Label("Accounts", systemImage: "building.columns.fill") }
.tag(SureTab.accounts)
SureBudgetsView()
.tabItem { Label("Budgets", systemImage: "chart.pie.fill") }
.tag(SureTab.budgets)
SureAssistantView()
.tabItem { Label("Assistant", systemImage: "sparkles") }
.tag(SureTab.assistant)
}
.onReceive(NotificationCenter.default.publisher(for: .sureOpenInsights)) { _ in
store.selectedTab = .overview
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import SwiftUI
struct SureOverviewView: View {
@Environment(SureStore.self) private var store
@State private var showingSettings = false
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(alignment: .leading, spacing: 22) {
if !store.insights.isEmpty {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("AI insights", systemImage: "sparkles")
.font(.title2.bold())
Spacer()
Text("LIVE")
.font(.caption2.bold())
.foregroundStyle(Color.sureMint)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.sureMint.opacity(0.12), in: .capsule)
}
Text("Proactive signals from your latest Sure data")
.font(.subheadline)
.foregroundStyle(.secondary)
ForEach(store.insights) { insight in
SureInsightCard(insight: insight)
}
}
}
if let balance = store.balanceSheet {
VStack(alignment: .leading, spacing: 18) {
Text("Net worth")
.font(.headline)
.foregroundStyle(.secondary)
Text(balance.netWorth.formatted)
.font(.system(.largeTitle, design: .rounded, weight: .bold))
.contentTransition(.numericText())
HStack(spacing: 12) {
SureMetricCard(title: "Assets", value: balance.assets.formatted, color: Color.sureMint)
SureMetricCard(title: "Liabilities", value: balance.liabilities.formatted, color: .orange)
}
}
.padding(20)
.background(.background.secondary, in: .rect(cornerRadius: 22))
}
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Accounts")
.font(.title2.bold())
Spacer()
Button("See all") { store.selectedTab = .accounts }
}
ForEach(store.accounts.prefix(4)) { account in
SureAccountRow(account: account)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.refreshable { await store.refreshAll() }
.navigationTitle("Overview")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Settings", systemImage: "gearshape") { showingSettings = true }
.labelStyle(.iconOnly)
}
}
.sheet(isPresented: $showingSettings) {
SureSettingsView()
}
.overlay {
if store.isLoading && store.balanceSheet == nil {
ProgressView("Loading your finances…")
}
}
}
}
}
struct SureInsightCard: View {
var insight: SureInsight
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: insight.symbol)
.font(.title3)
.foregroundStyle(insight.priority == "high" ? Color.orange : Color.sureIndigo)
.frame(width: 36, height: 36)
.background(
(insight.priority == "high" ? Color.orange : Color.sureIndigo).opacity(0.12),
in: .circle
)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 5) {
Text(insight.title)
.font(.headline)
Text(insight.body)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(Color.sureIndigo.opacity(0.055), in: .rect(cornerRadius: 18))
.accessibilityElement(children: .combine)
}
}
struct SureMetricCard: View {
var title: String
var value: String
var color: Color
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.headline)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14)
.background(color.opacity(0.1), in: .rect(cornerRadius: 14))
.accessibilityElement(children: .combine)
}
}
+16
View File
@@ -0,0 +1,16 @@
import SwiftUI
struct SureRootView: View {
@Environment(SureStore.self) private var store
var body: some View {
Group {
if store.isConfigured {
SureMainTabView()
} else {
SureSetupView()
}
}
.animation(.smooth, value: store.isConfigured)
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
import UserNotifications
struct SureSettingsView: View {
@Environment(SureStore.self) private var store
@Environment(\.dismiss) private var dismiss
@AppStorage("sure.insightNotifications") private var insightNotifications = false
@State private var notificationStatus = ""
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
Text("Connection")
.font(.headline)
Label(store.baseURLString, systemImage: "lock.fill")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(.background.secondary, in: .rect(cornerRadius: 18))
VStack(alignment: .leading, spacing: 12) {
Toggle("AI insight notifications", systemImage: "bell.badge.fill", isOn: $insightNotifications)
.onChange(of: insightNotifications) {
Task { await updateNotificationPreference() }
}
Text("Get notified when Sure finds a new proactive financial insight. You stay in control in iOS Settings.")
.font(.subheadline)
.foregroundStyle(.secondary)
if !notificationStatus.isEmpty {
Text(notificationStatus)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(18)
.background(.background.secondary, in: .rect(cornerRadius: 18))
Button(role: .destructive) {
Task {
await store.disconnect()
dismiss()
}
} label: {
Label("Disconnect this device", systemImage: "rectangle.portrait.and.arrow.right")
.frame(maxWidth: .infinity)
.padding(14)
.background(Color.red.opacity(0.09), in: .rect(cornerRadius: 14))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
.task { await readNotificationStatus() }
}
}
private func updateNotificationPreference() async {
if insightNotifications {
do {
let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge])
insightNotifications = granted
if granted {
await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
notificationStatus = "Notifications are enabled."
} else {
notificationStatus = "Notifications are off in iOS Settings."
}
} catch {
insightNotifications = false
notificationStatus = error.localizedDescription
}
} else {
await store.unregisterPushToken()
notificationStatus = "Notifications are disabled for this device."
}
}
private func readNotificationStatus() async {
let settings = await UNUserNotificationCenter.current().notificationSettings()
if settings.authorizationStatus == .denied {
insightNotifications = false
notificationStatus = "Notifications are off in iOS Settings."
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import SwiftUI
struct SureSetupView: View {
@Environment(SureStore.self) private var store
@State private var server = "https://demo.sure.am"
@State private var apiKey = ""
@FocusState private var focusedField: SetupField?
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 10) {
Image(systemName: "circle.hexagongrid.fill")
.font(.system(size: 52))
.foregroundStyle(.tint)
.accessibilityHidden(true)
Text("Your finances, made clear")
.font(.largeTitle.bold())
Text("Connect securely to Sure with an API key. Your key stays in this devices Keychain.")
.font(.title3)
.foregroundStyle(.secondary)
}
VStack(spacing: 16) {
TextField("Sure server", text: $server)
.textContentType(.URL)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .server)
.accessibilityLabel("Sure server address")
SecureField("Read/write API key", text: $apiKey)
.textContentType(.password)
.focused($focusedField, equals: .apiKey)
.accessibilityLabel("Sure read/write API key")
}
.padding(18)
.background(.background.secondary, in: .rect(cornerRadius: 20))
VStack(alignment: .leading, spacing: 10) {
Label("Connect to the demo", systemImage: "person.badge.key.fill")
.font(.headline)
Text("Sign in through the Sure demo, then create a read/write key in Settings → API keys. Paste that key above.")
.font(.subheadline)
.foregroundStyle(.secondary)
Link("Open Sure demo", destination: URL(string: "https://demo.sure.am")!)
.font(.subheadline.weight(.semibold))
}
.padding(16)
.background(Color.sureIndigo.opacity(0.09), in: .rect(cornerRadius: 16))
if let errorMessage = store.errorMessage {
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
.font(.subheadline)
.foregroundStyle(.red)
.accessibilityAddTraits(.isStaticText)
}
Button {
focusedField = nil
Task { await store.connect(baseURL: server, apiKey: apiKey) }
} label: {
HStack {
if store.isLoading {
ProgressView()
.tint(.white)
}
Text(store.isLoading ? "Connecting…" : "Connect to Sure")
.fontWeight(.semibold)
Spacer()
Image(systemName: "arrow.right")
}
.foregroundStyle(.white)
.padding()
.background(Color.sureIndigo, in: .rect(cornerRadius: 16))
}
.disabled(apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || store.isLoading)
}
.frame(maxWidth: 620, alignment: .leading)
.padding(24)
}
.navigationTitle("Sure")
}
}
}
private enum SetupField {
case server
case apiKey
}
+475
View File
@@ -0,0 +1,475 @@
import Foundation
import Observation
import UserNotifications
import UIKit
@MainActor
@Observable
final class SureStore {
var isConfigured = false
var isLoading = false
var errorMessage: String?
var balanceSheet: BalanceSheet?
var accounts: [SureAccount] = []
var budgets: [SureBudget] = []
var insights: [SureInsight] = []
var chats: [SureChat] = []
var selectedChat: SureChat?
var messages: [SureMessage] = []
var isAssistantThinking = false
var selectedTab = SureTab.overview
private(set) var baseURLString = "https://demo.sure.am"
private var client = SureAPIClient(baseURL: URL(string: "https://demo.sure.am")!, apiKey: "")
private var sessionGeneration = 0
private var chatGeneration = 0
func restoreSession() async {
let generation = sessionGeneration
baseURLString = UserDefaults.standard.string(forKey: "sure.baseURL") ?? "https://demo.sure.am"
guard let key = KeychainStore.readAPIKey(), !key.isEmpty,
let url = normalizedURL(baseURLString) else { return }
await client.update(baseURL: url, apiKey: key)
guard sessionGeneration == generation else { return }
do {
let balance: BalanceSheet = try await client.get("api/v1/balance_sheet")
guard sessionGeneration == generation else { return }
balanceSheet = balance
isConfigured = true
await refreshAll()
} catch {
guard sessionGeneration == generation else { return }
errorMessage = "Your saved connection needs attention. \(error.localizedDescription)"
}
}
func connect(baseURL: String, apiKey: String) async -> Bool {
guard let url = normalizedURL(baseURL) else {
errorMessage = "Enter a valid HTTPS server address."
return false
}
sessionGeneration &+= 1
chatGeneration &+= 1
let generation = sessionGeneration
isLoading = true
errorMessage = nil
await client.update(baseURL: url, apiKey: apiKey.trimmingCharacters(in: .whitespacesAndNewlines))
guard sessionGeneration == generation else { return false }
do {
let balance: BalanceSheet = try await client.get("api/v1/balance_sheet")
guard sessionGeneration == generation else { return false }
balanceSheet = balance
try KeychainStore.saveAPIKey(apiKey.trimmingCharacters(in: .whitespacesAndNewlines))
baseURLString = url.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
UserDefaults.standard.set(baseURLString, forKey: "sure.baseURL")
isConfigured = true
isLoading = false
await refreshAll()
return sessionGeneration == generation
} catch {
guard sessionGeneration == generation else { return false }
errorMessage = error.localizedDescription
isLoading = false
return false
}
}
func disconnect() async {
sessionGeneration &+= 1
chatGeneration &+= 1
await unregisterPushToken()
KeychainStore.deleteAPIKey()
UserDefaults.standard.removeObject(forKey: "sure.pushSubscriptionID")
UserDefaults.standard.set(false, forKey: "sure.insightNotifications")
isConfigured = false
isLoading = false
isAssistantThinking = false
errorMessage = nil
balanceSheet = nil
accounts = []
budgets = []
insights = []
chats = []
selectedChat = nil
messages = []
}
func refreshAll() async {
let generation = sessionGeneration
isLoading = true
errorMessage = nil
async let balanceRequest: BalanceSheet = client.get("api/v1/balance_sheet")
async let accountsRequest = loadAllPages(
path: "api/v1/accounts",
collection: AccountCollection.self,
items: \.accounts
)
async let budgetsRequest = loadAllPages(
path: "api/v1/budgets",
collection: BudgetCollection.self,
items: \.budgets
)
do {
let (balance, loadedAccounts, loadedBudgets) = try await (
balanceRequest,
accountsRequest,
budgetsRequest
)
guard sessionGeneration == generation else { return }
balanceSheet = balance
accounts = loadedAccounts
budgets = loadedBudgets
} catch {
guard sessionGeneration == generation else { return }
errorMessage = error.localizedDescription
isLoading = false
return
}
do {
let loadedChats = try await loadAllPages(
path: "api/v1/chats",
collection: ChatCollection.self,
items: \.chats
)
guard sessionGeneration == generation else { return }
chats = loadedChats
} catch let error as SureAPIError {
guard sessionGeneration == generation else { return }
if case .server(status: 403, message: _) = error {
chats = []
} else {
errorMessage = error.localizedDescription
}
} catch {
guard sessionGeneration == generation else { return }
errorMessage = error.localizedDescription
}
await loadInsights()
guard sessionGeneration == generation else { return }
if UserDefaults.standard.bool(forKey: "sure.insightNotifications") {
if let token = UserDefaults.standard.string(forKey: "sure.apnsDeviceToken") {
await registerPushToken(token)
}
} else {
await unregisterPushToken()
}
guard sessionGeneration == generation else { return }
isLoading = false
}
func loadInsights() async {
let generation = sessionGeneration
do {
let collection: InsightCollection = try await client.get("api/v1/insights")
guard sessionGeneration == generation else { return }
insights = collection.insights
} catch let error as SureAPIError {
guard sessionGeneration == generation else { return }
if case .server(status: 404, message: _) = error {
insights = makeLocalInsights()
} else if case .server(status: 403, message: _) = error {
insights = []
} else {
errorMessage = error.localizedDescription
}
} catch {
guard sessionGeneration == generation else { return }
errorMessage = error.localizedDescription
}
}
func loadChat(_ chat: SureChat) async {
chatGeneration &+= 1
let generation = sessionGeneration
let conversationGeneration = chatGeneration
selectedChat = chat
isAssistantThinking = false
do {
let detail = try await loadLatestChatDetail(chatID: chat.id)
guard sessionGeneration == generation,
chatGeneration == conversationGeneration,
selectedChat?.id == chat.id else { return }
messages = detail.messages.sorted { $0.createdAt < $1.createdAt }
} catch {
guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return }
errorMessage = error.localizedDescription
}
}
func newChat() {
chatGeneration &+= 1
selectedChat = nil
messages = []
isAssistantThinking = false
}
func sendMessage(_ content: String) async {
let text = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty, !isAssistantThinking else { return }
let generation = sessionGeneration
let conversationGeneration = chatGeneration
isAssistantThinking = true
errorMessage = nil
do {
if let selectedChat {
let request = MessageRequest(content: text)
let _: MessageReceipt = try await client.post(
"api/v1/chats/\(selectedChat.id)/messages",
body: request
)
guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return }
messages.append(SureMessage(
id: UUID().uuidString,
type: "user_message",
role: "user",
content: text,
createdAt: ISO8601DateFormatter().string(from: Date())
))
} else {
let request = NewChatRequest(title: makeTitle(from: text), message: text)
let detail: ChatDetail = try await client.post("api/v1/chats", body: request)
guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return }
selectedChat = detail.chat
messages = detail.messages.sorted { $0.createdAt < $1.createdAt }
}
try await pollForAssistantResponse(
sessionGeneration: generation,
chatGeneration: conversationGeneration
)
guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return }
let loadedChats = try await loadAllPages(
path: "api/v1/chats",
collection: ChatCollection.self,
items: \.chats
)
guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return }
chats = loadedChats
} catch {
guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return }
errorMessage = error.localizedDescription
}
guard sessionGeneration == generation, chatGeneration == conversationGeneration else { return }
isAssistantThinking = false
}
func registerPushToken(_ token: String) async {
guard isConfigured, UserDefaults.standard.bool(forKey: "sure.insightNotifications") else { return }
let generation = sessionGeneration
let environment = APNsEnvironment.current
let request = PushSubscriptionRequest(
token: token,
environment: environment.rawValue,
platform: "ios"
)
do {
let receipt: PushSubscriptionReceipt = try await client.post(
"api/v1/push_subscriptions",
body: request
)
guard sessionGeneration == generation else {
try? await client.delete("api/v1/push_subscriptions/\(receipt.id)")
return
}
UserDefaults.standard.set(receipt.id, forKey: "sure.pushSubscriptionID")
} catch let error as SureAPIError {
guard sessionGeneration == generation else { return }
if case .server(status: 404, message: _) = error { return }
errorMessage = error.localizedDescription
} catch {
guard sessionGeneration == generation else { return }
errorMessage = error.localizedDescription
}
}
func unregisterPushToken() async {
guard isConfigured,
let id = UserDefaults.standard.string(forKey: "sure.pushSubscriptionID") else { return }
let generation = sessionGeneration
do {
try await client.delete("api/v1/push_subscriptions/\(id)")
guard sessionGeneration == generation else { return }
UserDefaults.standard.removeObject(forKey: "sure.pushSubscriptionID")
} catch let error as SureAPIError {
guard sessionGeneration == generation else { return }
if case .server(status: 404, message: _) = error {
UserDefaults.standard.removeObject(forKey: "sure.pushSubscriptionID")
return
}
errorMessage = error.localizedDescription
} catch {
guard sessionGeneration == generation else { return }
errorMessage = error.localizedDescription
}
}
private func pollForAssistantResponse(sessionGeneration: Int, chatGeneration: Int) async throws {
guard let chat = selectedChat else { return }
let previousAssistantIDs = Set(messages.filter { !$0.isUser }.map(\.id))
let retryDelays = [3, 5, 8, 13, 21, 34]
for delay in retryDelays {
try await Task.sleep(for: .seconds(delay))
guard self.sessionGeneration == sessionGeneration,
self.chatGeneration == chatGeneration,
!Task.isCancelled else { return }
let detail: ChatDetail
do {
detail = try await loadLatestChatDetail(chatID: chat.id)
} catch let error as SureAPIError {
if case .server(status: 429, message: _) = error {
throw error
}
continue
} catch {
continue
}
guard self.sessionGeneration == sessionGeneration,
self.chatGeneration == chatGeneration else { return }
messages = detail.messages.sorted { $0.createdAt < $1.createdAt }
if messages.contains(where: { !$0.isUser && !previousAssistantIDs.contains($0.id) && !$0.content.isEmpty }) {
return
}
}
guard self.sessionGeneration == sessionGeneration, self.chatGeneration == chatGeneration else { return }
errorMessage = "The assistant is still working. Pull to refresh this conversation in a moment."
}
private func loadAllPages<Collection: Decodable & Sendable, Item: Sendable>(
path: String,
collection: Collection.Type,
items: KeyPath<Collection, [Item]>
) async throws -> [Item] where Collection: PaginatedCollection {
let firstPage = try await client.get("\(path)?page=1&per_page=100", as: collection)
var allItems = firstPage[keyPath: items]
let totalPages = firstPage.pagination?.totalPages ?? 1
guard totalPages > 1 else { return allItems }
for page in 2...totalPages {
let nextPage = try await client.get("\(path)?page=\(page)&per_page=100", as: collection)
allItems.append(contentsOf: nextPage[keyPath: items])
}
return allItems
}
private func loadLatestChatDetail(chatID: String) async throws -> ChatDetail {
let firstPage: ChatDetail = try await client.get("api/v1/chats/\(chatID)")
guard let pagination = firstPage.pagination,
pagination.totalPages > pagination.page else { return firstPage }
return try await client.get("api/v1/chats/\(chatID)?page=\(pagination.totalPages)")
}
private func makeLocalInsights() -> [SureInsight] {
var generated: [SureInsight] = []
if let balanceSheet {
let liabilities = balanceSheet.liabilities.decimalAmount.magnitude
let assets = balanceSheet.assets.decimalAmount.magnitude
if liabilities > 0, assets > 0 {
let ratio = NSDecimalNumber(decimal: liabilities / assets).doubleValue
generated.append(SureInsight(
id: "live-debt-ratio",
type: ratio > 0.5 ? "cash_flow_warning" : "budget_on_track",
title: ratio > 0.5 ? "Liabilities need attention" : "Your balance sheet looks resilient",
body: "Liabilities are \((ratio * 100).formatted(.number.precision(.fractionLength(0))))% of assets, based on your live Sure balances.",
priority: ratio > 0.5 ? "high" : "low",
status: "active"
))
}
}
if let largest = accounts.filter(\.isAsset).max(by: { abs($0.balanceCents) < abs($1.balanceCents) }) {
generated.append(SureInsight(
id: "live-largest-account",
type: "idle_cash",
title: "Review your largest account",
body: "\(largest.name) holds \(largest.balance). Ask the Assistant whether that concentration fits your goals.",
priority: "medium",
status: "active"
))
}
if let currentBudget = budgets.first(where: \.current) {
generated.append(SureInsight(
id: "live-current-budget",
type: "budget_on_track",
title: "Your current plan is ready to review",
body: "\(currentBudget.name) has \(currentBudget.allocatedSpending) allocated. Check it before the period ends.",
priority: "low",
status: "active"
))
}
return Array(generated.prefix(3))
}
private func makeTitle(from content: String) -> String {
String(content.prefix(48))
}
private func normalizedURL(_ input: String) -> URL? {
guard var components = URLComponents(string: input.trimmingCharacters(in: .whitespacesAndNewlines)),
components.scheme == "https", components.host != nil else { return nil }
components.path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/"
return components.url
}
}
enum SureTab: Hashable {
case overview
case accounts
case budgets
case assistant
}
protocol PaginatedCollection {
var pagination: SurePagination? { get }
}
extension AccountCollection: PaginatedCollection {}
extension BudgetCollection: PaginatedCollection {}
extension ChatCollection: PaginatedCollection {}
struct NewChatRequest: Codable, Sendable {
var title: String
var message: String
}
struct MessageRequest: Codable, Sendable {
var content: String
}
struct MessageReceipt: Codable, Sendable {
var id: String
var chatId: String
enum CodingKeys: String, CodingKey {
case id
case chatId = "chat_id"
}
}
struct PushSubscriptionRequest: Codable, Sendable {
var token: String
var environment: String
var platform: String
}
struct PushSubscriptionReceipt: Codable, Sendable {
var id: String
}
enum APNsEnvironment: String {
case sandbox
case production
static var current: APNsEnvironment {
#if targetEnvironment(simulator)
return .sandbox
#else
guard let profileURL = Bundle.main.url(forResource: "embedded", withExtension: "mobileprovision"),
let profileData = try? Data(contentsOf: profileURL),
let profileText = String(data: profileData, encoding: .isoLatin1) else {
return .production
}
return profileText.contains("<string>development</string>") ? .sandbox : .production
#endif
}
}
+17
View File
@@ -0,0 +1,17 @@
# Sure for iOS and iPadOS
This SwiftUI client connects directly to a Sure instance with `X-Api-Key` authentication.
## Demo connection
- Server: `https://demo.sure.am`
- Sign in through the demo website using the credentials presented there.
- Create a **read/write** key under Settings → API keys, then paste it into the native app. The key is stored only in the device Keychain.
Do not commit an API key. The shared demo account is refreshed regularly, so a generated key may need to be recreated.
## AI insight notifications
The app target includes the APNs entitlement and registers device tokens only after the user enables AI insight notifications. Tokens are sent to `POST /api/v1/push_subscriptions` with their sandbox or production environment. The project source intentionally keeps `aps-environment` set to `development`; Apple distribution signing replaces it with `production` for TestFlight and App Store builds.
Server delivery additionally requires a paid Apple Developer Program membership and these backend secrets: `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, and `APNS_PRIVATE_KEY_BASE64` (the base64-encoded `.p8` contents). Never add their values to this app or repository. Newly created or resurfaced insights enqueue one APNs delivery per recently registered device; development installs use APNs sandbox, while TestFlight and App Store builds use production APNs.
+3
View File
@@ -1,6 +1,9 @@
---
de:
insights:
notification:
title: Neue Finanzanalyse
body: Öffne Sure, um deine neueste KI-Analyse anzusehen.
actions:
budget: Budget ansehen
cash_flow_warning: Wiederkehrende Buchungen prüfen
+3
View File
@@ -1,6 +1,9 @@
---
en:
insights:
notification:
title: New financial insight
body: Open Sure to review your latest AI insight.
index:
title: Insights
subtitle: What's happening across your finances, refreshed nightly.
+3
View File
@@ -1,6 +1,9 @@
---
es:
insights:
notification:
title: Nuevo análisis financiero
body: Abre Sure para revisar tu análisis de IA más reciente.
index:
title: Insights
subtitle: Qué está pasando en tus finanzas, actualizado cada noche.
+3
View File
@@ -1,6 +1,9 @@
---
fr:
insights:
notification:
title: Nouvelle analyse financière
body: Ouvrez Sure pour consulter votre dernière analyse par IA.
actions:
budget: Voir le budget
cash_flow_warning: Vérifier les transactions récurrentes
+3
View File
@@ -1,6 +1,9 @@
---
pl:
insights:
notification:
title: Nowa analiza finansowa
body: Otwórz Sure, aby przejrzeć najnowszą analizę AI.
index:
title: Analityka
subtitle: Co dzieje się w twoich finansach, odświeżane codziennie.
+3
View File
@@ -1,6 +1,9 @@
---
tr:
insights:
notification:
title: Yeni finansal içgörü
body: En son yapay zekâ içgörünüzü incelemek için Sure'ı açın.
actions:
budget: Bütçeyi görüntüle
cash_flow_warning: Yinelenen işlemleri incele
+3
View File
@@ -1,5 +1,8 @@
uk:
insights:
notification:
title: Новий фінансовий інсайт
body: Відкрийте Sure, щоб переглянути найновіший інсайт від ШІ.
index:
title: Аналітика
subtitle: Комплексний огляд ваших фінансів, оновлюється щоночі.
+3
View File
@@ -1,6 +1,9 @@
---
zh-TW:
insights:
notification:
title: 新的財務洞察
body: 開啟 Sure 以查看最新的 AI 洞察。
index:
title: 洞察
subtitle: 您財務狀況的近期動態,每晚更新。
+2
View File
@@ -669,6 +669,8 @@ Rails.application.routes.draw do
end
resource :usage, only: [ :show ], controller: :usage
resource :balance_sheet, only: [ :show ], controller: :balance_sheet
resources :insights, only: [ :index ]
resources :push_subscriptions, only: [ :create, :destroy ]
resource :family_settings, only: [ :show ], controller: :family_settings
post :sync, to: "sync#create", as: :sync_job
resources :syncs, only: [ :index, :show ] do
@@ -0,0 +1,24 @@
class CreatePushSubscriptions < ActiveRecord::Migration[7.2]
def change
create_table :push_subscriptions, id: :uuid do |t|
t.references :user, null: false, foreign_key: true, type: :uuid
t.string :token, null: false
t.string :environment, null: false
t.string :platform, null: false, default: "ios"
t.datetime :last_registered_at, null: false
t.timestamps
end
add_index :push_subscriptions, "lower(token)",
unique: true,
name: "index_push_subscriptions_on_lower_token"
add_index :push_subscriptions, :last_registered_at
add_check_constraint :push_subscriptions,
"environment IN ('sandbox', 'production')",
name: "chk_push_subscriptions_environment"
add_check_constraint :push_subscriptions,
"platform = 'ios'",
name: "chk_push_subscriptions_platform"
end
end
Generated
+16
View File
@@ -1625,6 +1625,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_22_130000) do
t.index ["provider_key", "period"], name: "index_provider_request_counts_on_provider_key_and_period", unique: true
end
create_table "push_subscriptions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.string "token", null: false
t.string "environment", null: false
t.string "platform", default: "ios", null: false
t.datetime "last_registered_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["last_registered_at"], name: "index_push_subscriptions_on_last_registered_at"
t.index "lower((token)::text)", name: "index_push_subscriptions_on_lower_token", unique: true
t.index ["user_id"], name: "index_push_subscriptions_on_user_id"
t.check_constraint "environment::text = ANY (ARRAY['sandbox'::character varying, 'production'::character varying]::text[])", name: "chk_push_subscriptions_environment"
t.check_constraint "platform::text = 'ios'::text", name: "chk_push_subscriptions_platform"
end
create_table "questrade_accounts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "account_number"
t.string "account_status"
@@ -2492,6 +2507,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_22_130000) do
add_foreign_key "onchain_wallet_items", "families"
add_foreign_key "plaid_accounts", "plaid_items"
add_foreign_key "plaid_items", "families"
add_foreign_key "push_subscriptions", "users"
add_foreign_key "questrade_accounts", "questrade_items"
add_foreign_key "questrade_items", "families"
add_foreign_key "recurring_transactions", "accounts", column: "destination_account_id", on_delete: :cascade
+145
View File
@@ -301,6 +301,72 @@ components:
"$ref": "#/components/schemas/ChatSummary"
pagination:
"$ref": "#/components/schemas/Pagination"
Insight:
type: object
required:
- id
- type
- title
- body
- priority
- status
properties:
id:
type: string
format: uuid
type:
type: string
title:
type: string
body:
type: string
priority:
type: string
enum:
- high
- medium
- low
status:
type: string
enum:
- active
- read
generated_at:
type: string
format: date-time
nullable: true
InsightCollection:
type: object
required:
- insights
properties:
insights:
type: array
items:
"$ref": "#/components/schemas/Insight"
PushSubscription:
type: object
required:
- id
- environment
- platform
- last_registered_at
properties:
id:
type: string
format: uuid
environment:
type: string
enum:
- sandbox
- production
platform:
type: string
enum:
- ios
last_registered_at:
type: string
format: date-time
RetryResponse:
type: object
required:
@@ -4515,6 +4581,85 @@ paths:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/insights":
get:
summary: List proactive insights
tags:
- Insights
security:
- apiKeyAuth: []
responses:
'200':
description: insights listed
content:
application/json:
schema:
"$ref": "#/components/schemas/InsightCollection"
'403':
description: preview features disabled
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
"/api/v1/push_subscriptions":
post:
summary: Register an APNs device token
tags:
- Push Subscriptions
security:
- apiKeyAuth: []
parameters: []
responses:
'201':
description: token registered
content:
application/json:
schema:
"$ref": "#/components/schemas/PushSubscription"
'422':
description: invalid or conflicting subscription
content:
application/json:
schema:
"$ref": "#/components/schemas/ErrorResponse"
requestBody:
content:
application/json:
schema:
type: object
required:
- token
- environment
- platform
properties:
token:
type: string
environment:
type: string
enum:
- sandbox
- production
platform:
type: string
enum:
- ios
required: true
"/api/v1/push_subscriptions/{id}":
parameters:
- name: id
in: path
required: true
schema:
type: string
delete:
summary: Unregister an APNs device token
tags:
- Push Subscriptions
security:
- apiKeyAuth: []
responses:
'204':
description: token unregistered
"/api/v1/family_exports":
get:
summary: Lists family exports
+48
View File
@@ -0,0 +1,48 @@
# frozen_string_literal: true
require "swagger_helper"
RSpec.describe "API V1 Insights", type: :request do
let(:family) { Family.create!(name: "API Family") }
let(:user) do
family.users.create!(
email: "insights-api@example.com",
password: "password123",
ai_enabled: true,
preferences: { "preview_features_enabled" => true }
)
end
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(user: user, name: "API Docs Key", key: key, scopes: %w[read_write], source: "web")
end
let(:"X-Api-Key") { api_key.plain_key }
path "/api/v1/insights" do
get "List proactive insights" do
tags "Insights"
security [ { apiKeyAuth: [] } ]
produces "application/json"
response "200", "insights listed" do
schema "$ref" => "#/components/schemas/InsightCollection"
run_test!
end
response "403", "preview features disabled" do
schema "$ref" => "#/components/schemas/ErrorResponse"
let(:user) do
family.users.create!(
email: "insights-disabled-api@example.com",
password: "password123",
ai_enabled: true,
preferences: { "preview_features_enabled" => false }
)
end
run_test!
end
end
end
end
@@ -0,0 +1,70 @@
# frozen_string_literal: true
require "swagger_helper"
RSpec.describe "API V1 Push Subscriptions", type: :request do
let(:family) { Family.create!(name: "API Family") }
let(:user) do
family.users.create!(email: "push-api@example.com", password: "password123", ai_enabled: true)
end
let(:api_key) do
key = ApiKey.generate_secure_key
ApiKey.create!(user: user, name: "API Docs Key", key: key, scopes: %w[read_write], source: "web")
end
let(:"X-Api-Key") { api_key.plain_key }
path "/api/v1/push_subscriptions" do
post "Register an APNs device token" do
tags "Push Subscriptions"
security [ { apiKeyAuth: [] } ]
consumes "application/json"
produces "application/json"
parameter name: :subscription, in: :body, required: true, schema: {
type: :object,
required: %w[token environment platform],
properties: {
token: { type: :string },
environment: { type: :string, enum: %w[sandbox production] },
platform: { type: :string, enum: %w[ios] }
}
}
let(:subscription) { { token: "ab" * 32, environment: "sandbox", platform: "ios" } }
response "201", "token registered" do
schema "$ref" => "#/components/schemas/PushSubscription"
run_test!
end
response "422", "invalid or conflicting subscription" do
schema "$ref" => "#/components/schemas/ErrorResponse"
let(:subscription) { { token: "ab" * 32, environment: "staging", platform: "ios" } }
run_test!
end
end
end
path "/api/v1/push_subscriptions/{id}" do
parameter name: :id, in: :path, type: :string, required: true
let(:subscription) do
user.push_subscriptions.create!(
token: "cd" * 32,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
end
let(:id) { subscription.id }
delete "Unregister an APNs device token" do
tags "Push Subscriptions"
security [ { apiKeyAuth: [] } ]
response "204", "token unregistered" do
run_test!
end
end
end
end
+30
View File
@@ -225,6 +225,36 @@ RSpec.configure do |config|
pagination: { '$ref' => '#/components/schemas/Pagination' }
}
},
Insight: {
type: :object,
required: %w[id type title body priority status],
properties: {
id: { type: :string, format: :uuid },
type: { type: :string },
title: { type: :string },
body: { type: :string },
priority: { type: :string, enum: %w[high medium low] },
status: { type: :string, enum: %w[active read] },
generated_at: { type: :string, format: :'date-time', nullable: true }
}
},
InsightCollection: {
type: :object,
required: %w[insights],
properties: {
insights: { type: :array, items: { '$ref' => '#/components/schemas/Insight' } }
}
},
PushSubscription: {
type: :object,
required: %w[id environment platform last_registered_at],
properties: {
id: { type: :string, format: :uuid },
environment: { type: :string, enum: %w[sandbox production] },
platform: { type: :string, enum: %w[ios] },
last_registered_at: { type: :string, format: :'date-time' }
}
},
RetryResponse: {
type: :object,
required: %w[message message_id],
@@ -0,0 +1,50 @@
require "test_helper"
class Api::V1::InsightsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
@user.update!(preferences: @user.preferences.merge("preview_features_enabled" => true))
key = ApiKey.generate_secure_key
@api_key = ApiKey.create!(
user: @user,
name: "Native insights test",
key: key,
scopes: [ "read" ],
source: "mobile"
)
@insight = @user.family.insights.create!(
insight_type: "idle_cash",
priority: "medium",
status: "active",
title: "Put idle cash to work",
body: "One account has more cash than usual.",
generated_at: Time.current,
dedup_key: "native-insights-test"
)
end
test "lists visible family insights" do
get api_v1_insights_url, headers: api_headers(@api_key)
assert_response :success
payload = response.parsed_body
row = payload.fetch("insights").find { |insight| insight.fetch("id") == @insight.id }
assert_equal "idle_cash", row.fetch("type")
assert_equal "Put idle cash to work", row.fetch("title")
end
test "rejects requests without an API key" do
get api_v1_insights_url
assert_response :unauthorized
end
test "does not expose insights when the API key owner opted out of preview features" do
@user.update!(preferences: @user.preferences.merge("preview_features_enabled" => false))
get api_v1_insights_url, headers: api_headers(@api_key)
assert_response :forbidden
assert_equal "feature_disabled", response.parsed_body.fetch("error")
end
end
@@ -0,0 +1,160 @@
require "test_helper"
class Api::V1::PushSubscriptionsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:family_admin)
key = ApiKey.generate_secure_key
@api_key = ApiKey.create!(
user: @user,
name: "Native push test",
key: key,
scopes: [ "read_write" ],
source: "mobile"
)
@headers = api_headers(@api_key)
@token = "ab" * 32
end
test "registers and refreshes an APNs token" do
assert_difference "PushSubscription.count", 1 do
post api_v1_push_subscriptions_url,
params: { token: @token, environment: "sandbox", platform: "ios" },
headers: @headers,
as: :json
end
assert_response :created
subscription = PushSubscription.find_by!(token: @token)
assert_equal @user, subscription.user
assert_equal "sandbox", subscription.environment
assert_no_difference "PushSubscription.count" do
post api_v1_push_subscriptions_url,
params: { token: @token, environment: "production", platform: "ios" },
headers: @headers,
as: :json
end
assert_equal "production", subscription.reload.environment
end
test "requires a read write API key" do
read_key_value = ApiKey.generate_secure_key
read_key = ApiKey.create!(
user: @user,
name: "Read-only native push test",
key: read_key_value,
scopes: [ "read" ],
source: "mobile"
)
post api_v1_push_subscriptions_url,
params: { token: @token, environment: "sandbox", platform: "ios" },
headers: api_headers(read_key),
as: :json
assert_response :forbidden
end
test "rejects malformed tokens" do
post api_v1_push_subscriptions_url,
params: { token: "not-a-device-token", environment: "sandbox", platform: "ios" },
headers: @headers,
as: :json
assert_response :unprocessable_entity
end
test "rejects an invalid APNs environment" do
post api_v1_push_subscriptions_url,
params: { token: @token, environment: "staging", platform: "ios" },
headers: @headers,
as: :json
assert_response :unprocessable_entity
end
test "normalizes APNs tokens before lookup and persistence" do
post api_v1_push_subscriptions_url,
params: { token: @token.upcase, environment: "sandbox", platform: "ios" },
headers: @headers,
as: :json
assert_response :created
assert PushSubscription.exists?(token: @token)
assert_no_difference "PushSubscription.count" do
post api_v1_push_subscriptions_url,
params: { token: @token, environment: "sandbox", platform: "ios" },
headers: @headers,
as: :json
end
end
test "does not transfer another user's token" do
other_user = users(:empty)
subscription = other_user.push_subscriptions.create!(
token: @token,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
post api_v1_push_subscriptions_url,
params: { token: @token.upcase, environment: "production", platform: "ios" },
headers: @headers,
as: :json
assert_response :unprocessable_entity
assert_equal other_user, subscription.reload.user
assert_equal "sandbox", subscription.environment
end
test "returns a controlled response when concurrent token registration conflicts" do
PushSubscription.any_instance.stubs(:save!).raises(ActiveRecord::RecordNotUnique)
post api_v1_push_subscriptions_url,
params: { token: @token, environment: "sandbox", platform: "ios" },
headers: @headers,
as: :json
assert_response :unprocessable_entity
assert_equal "validation_error", response.parsed_body["error"]
end
test "removes the current user's token" do
subscription = @user.push_subscriptions.create!(
token: @token,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
assert_difference "PushSubscription.count", -1 do
delete api_v1_push_subscription_url(subscription), headers: @headers
end
assert_response :no_content
end
test "does not remove another user's token" do
subscription = users(:empty).push_subscriptions.create!(
token: @token,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
delete api_v1_push_subscription_url(subscription), headers: @headers
assert_response :not_found
assert PushSubscription.exists?(subscription.id)
end
test "requires authentication" do
post api_v1_push_subscriptions_url,
params: { token: @token, environment: "sandbox", platform: "ios" },
as: :json
assert_response :unauthorized
end
end
@@ -0,0 +1,75 @@
require "test_helper"
class DeliverInsightNotificationJobTest < ActiveJob::TestCase
setup do
@insight = insights(:cash_flow_warning)
@subscription = @insight.family.users.first.push_subscriptions.create!(
token: "ab" * 32,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
end
test "delivers a privacy-preserving insight notification" do
response = stub(ok?: true)
client = mock
Apns::Client.expects(:new).with(environment: "sandbox").returns(client)
client.expects(:deliver).with(
token: @subscription.token,
title: "New financial insight",
body: "Open Sure to review your latest AI insight.",
insight_id: @insight.id
).returns(response)
DeliverInsightNotificationJob.perform_now(
insight_id: @insight.id,
push_subscription_id: @subscription.id
)
end
test "localizes notifications using the family locale" do
@insight.family.update!(locale: "de")
response = stub(ok?: true)
client = mock
Apns::Client.stubs(:new).returns(client)
client.expects(:deliver).with(
token: @subscription.token,
title: "Neue Finanzanalyse",
body: "Öffne Sure, um deine neueste KI-Analyse anzusehen.",
insight_id: @insight.id
).returns(response)
DeliverInsightNotificationJob.perform_now(
insight_id: @insight.id,
push_subscription_id: @subscription.id
)
end
test "removes tokens rejected as unregistered" do
response = stub(ok?: false, status: "410", body: { "reason" => "Unregistered" })
Apns::Client.any_instance.stubs(:deliver).returns(response)
assert_difference "PushSubscription.count", -1 do
DeliverInsightNotificationJob.perform_now(
insight_id: @insight.id,
push_subscription_id: @subscription.id
)
end
end
test "does not send an insight to a device from another family" do
other_subscription = users(:empty).push_subscriptions.create!(
token: "cd" * 32,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
Apns::Client.expects(:new).never
DeliverInsightNotificationJob.perform_now(
insight_id: @insight.id,
push_subscription_id: other_subscription.id
)
end
end
+32
View File
@@ -89,6 +89,38 @@ class GenerateInsightsJobTest < ActiveJob::TestCase
assert_equal 5000.0, insight.metadata["balance"]
end
test "enqueues notifications for newly created insights" do
opted_in_user, opted_out_user = @family.users.to_a
set_preview_features(opted_out_user, false)
subscription = opted_in_user.push_subscriptions.create!(
token: "ab" * 32,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
opted_out_user.push_subscriptions.create!(
token: "cd" * 32,
environment: "sandbox",
platform: "ios",
last_registered_at: Time.current
)
Apns::Client.stubs(:configured?).returns(true)
stub_generated([ generated_insight ])
assert_enqueued_jobs 1, only: DeliverInsightNotificationJob do
assert_enqueued_with(
job: DeliverInsightNotificationJob,
args: ->(args) {
args.one? &&
args.first[:insight_id].present? &&
args.first[:push_subscription_id] == subscription.id
}
) do
GenerateInsightsJob.perform_now(family_id: @family.id)
end
end
end
test "re-running with unchanged numbers does not duplicate or rewrite" do
stub_generated([ generated_insight ])
GenerateInsightsJob.perform_now(family_id: @family.id)
+40
View File
@@ -0,0 +1,40 @@
require "test_helper"
class Apns::ClientTest < ActiveSupport::TestCase
setup do
@environment = {
"APNS_KEY_ID" => "key-id",
"APNS_TEAM_ID" => "team-id",
"APNS_BUNDLE_ID" => "com.example.sure",
"APNS_PRIVATE_KEY_BASE64" => Base64.strict_encode64("private-key")
}
end
test "sends sandbox notifications with token authentication" do
connection = mock
response = stub(ok?: true)
Apnotic::Connection.expects(:development).with do |options|
options[:auth_method] == :token &&
options[:key_id] == "key-id" &&
options[:team_id] == "team-id" &&
options[:cert_path].read == "private-key"
end.returns(connection)
connection.expects(:push).with do |notification|
notification.topic == "com.example.sure" &&
notification.push_type == "alert" &&
notification.custom_payload == { insight_id: "insight-id", destination: "insights" }
end.returns(response)
connection.expects(:close)
ClimateControl.modify(@environment) do
result = Apns::Client.new(environment: "sandbox").deliver(
token: "ab" * 32,
title: "New financial insight",
body: "Open Sure to review your latest AI insight.",
insight_id: "insight-id"
)
assert result.ok?
end
end
end