Files
caravel/frontend/src/lib/state.ts
T
Jon Staab 451264106a
Docker / build-and-push-image (push) Has been cancelled
Make logo customizable
2026-06-03 16:46:20 -07:00

220 lines
8.8 KiB
TypeScript

import { createResource, createSignal } from "solid-js"
import { AccountManager } from "applesauce-accounts"
import type { IAccount } from "applesauce-accounts"
import { registerCommonAccountTypes } from "applesauce-accounts/accounts"
import { EventStore } from "applesauce-core"
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
import { RelayPool } from "applesauce-relay"
import { NostrConnectSigner } from "applesauce-signers"
import { createTenant, getDraftInvoice, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, reconcileInvoice, reconcileTenant, registerAccountGetter, selectPayableInvoice, type Plan } from "@/lib/api"
import { autopayConfigured } from "@/lib/paymentMethod"
export type UnsignedEvent = {
kind: number
content: string
created_at: number
tags: string[][]
}
export type SignedEvent = UnsignedEvent & {
id: string
pubkey: string
sig: string
}
export type EventSigner = {
signEvent(event: UnsignedEvent): Promise<SignedEvent>
}
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME
export const PLATFORM_LOGO = import.meta.env.VITE_PLATFORM_LOGO
export const eventStore = new EventStore()
export const pool = new RelayPool()
createEventLoaderForStore(eventStore, pool, {
lookupRelays: ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://indexer.coracle.social/"],
extraRelays: ["wss://relay.damus.io/", "wss://nos.lol/", "wss://relay.primal.net/"],
})
NostrConnectSigner.subscriptionMethod = pool.subscription.bind(pool)
NostrConnectSigner.publishMethod = pool.publish.bind(pool)
export const accountManager = new AccountManager()
registerCommonAccountTypes(accountManager)
export const [account, setAccount] = createSignal<IAccount | undefined>()
registerAccountGetter(account)
export type ToastVariant = "error" | "success"
export const [toastVariant, setToastVariant] = createSignal<ToastVariant>("error")
export const [toastMessage, setRawToastMessage] = createSignal("")
export function setToastMessage(message: string, variant: ToastVariant = "error") {
setToastVariant(variant)
setRawToastMessage(message)
}
// Set while the create/upgrade flow drives its own payment/setup modals, so the
// shared prompt surface doesn't double-prompt for the same invoice. Cleared on
// every close path of that flow.
export const [billingFlowActive, setBillingFlowActive] = createSignal(false)
export const [plans] = createResource<Plan[]>(listPlans, { initialValue: [] })
export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = createResource(
() => account()?.pubkey,
pubkey => {
if (pubkey) return getIdentity()
}
)
// Shared billing reads, fetched once per session and consumed by the dashboard
// shell, the billing page, and the billing-prompt surface. They're gated on
// billingPubkey rather than the active account directly: the tenant row is
// provisioned lazily on first login, so getTenant/listTenantInvoices 404 until
// ensureSessionTenant() has created it. billingPubkey is reset on activation and
// re-set once the tenant is ensured, so the reads still refetch on account switch
// without racing ahead of provisioning. refetchBilling() refreshes them all after
// a mutation (payment, method update, plan change).
const [billingPubkey, setBillingPubkey] = createSignal<string>()
const billingKey = () => billingPubkey()
export const [billingTenant, { refetch: refetchBillingTenant }] = createResource(billingKey, getTenant)
export const [billingInvoices, { refetch: refetchBillingInvoices }] = createResource(billingKey, listTenantInvoices)
export const [billingRelays, { refetch: refetchBillingRelays }] = createResource(billingKey, listTenantRelays)
export const [billingDraftInvoice, { refetch: refetchBillingDraftInvoice }] = createResource(billingKey, getDraftInvoice)
export function refetchBilling() {
void Promise.allSettled([
refetchBillingTenant(),
refetchBillingInvoices(),
refetchBillingRelays(),
refetchBillingDraftInvoice(),
]).then(results => {
if (results.some(r => r.status === "rejected")) {
const err = results.find(r => r.status === "rejected") as PromiseRejectedResult | undefined
console.error("Failed to refresh billing data", err?.reason)
setToastMessage("Failed to refresh billing data")
}
})
}
// In-flight autopay run, keyed by pubkey, so concurrent triggers (a mount
// double-fire, two dialog onClose handlers) collapse into one run.
let autopayInFlight: { pubkey: string; promise: Promise<void> } | undefined
// The side-effecting billing refresh, layered above the pure refetchBilling: on
// load of the billing surface it reconciles the subscription (materializing the
// current invoice), syncs the Stripe payment method (picking up a portal-added
// card), and — when a method is on file and an invoice is due — collects it,
// then refreshes all billing reads. This is what makes "add a card, return to
// the app" actually pay the open invoice. Payment is skipped while the
// create/upgrade flow owns the invoice (billingFlowActive).
export function autopayBilling(pubkey: string): Promise<void> {
if (autopayInFlight?.pubkey === pubkey) return autopayInFlight.promise
const promise = (async () => {
try {
// The tenant row is provisioned lazily on first login; make sure it exists
// before the tenant-scoped POSTs, since autopay can fire (on the Account
// landing effect) before provisioning has completed.
if (billingPubkey() !== pubkey) await ensureSessionTenant()
// Sync the payment method + reconcile the subscription, then collect the
// oldest open invoice when a method is on file (and the create/upgrade
// flow isn't already driving its own invoice).
const tenant = await reconcileTenant(pubkey)
const invoice = selectPayableInvoice(await listTenantInvoices(pubkey))
if (invoice && autopayConfigured(tenant) && !billingFlowActive()) {
await reconcileInvoice(invoice.id)
}
} catch (e) {
console.error("Autopay billing failed", e)
setToastMessage(e instanceof Error ? e.message : "Failed to update billing")
} finally {
// Reflect the final state (paid invoice, cleared errors, relay changes).
refetchBilling()
if (autopayInFlight?.pubkey === pubkey) autopayInFlight = undefined
}
})()
autopayInFlight = { pubkey, promise }
return promise
}
// Ensure the active pubkey's tenant row exists, then unlock billing reads. The
// tenant is created lazily on first login, so this must run before any
// tenant-scoped read. The in-flight promise is shared so the login flow (which
// awaits it to roll back on failure) and the activation subscriber below don't
// double-provision; createTenant is itself idempotent.
let tenantEnsure: { pubkey: string; promise: Promise<void> } | undefined
export function ensureSessionTenant(): Promise<void> {
const pubkey = account()?.pubkey
if (!pubkey) return Promise.resolve()
if (tenantEnsure?.pubkey === pubkey) return tenantEnsure.promise
const promise = (async () => {
try {
await createTenant()
if (account()?.pubkey === pubkey) setBillingPubkey(pubkey)
} finally {
if (tenantEnsure?.pubkey === pubkey) tenantEnsure = undefined
}
})()
tenantEnsure = { pubkey, promise }
return promise
}
// Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts)
queueMicrotask(() => {
try {
accountManager.fromJSON(JSON.parse(localStorage.getItem("caravel.accounts")!))
} catch {
// pass
}
const active = localStorage.getItem("caravel.accounts.active")
if (active) {
accountManager.setActive(active)
}
// Held for the whole app session: this callback persists accounts to
// localStorage and ensures the session tenant on every switch, so it must
// never be torn down by a component lifecycle. The only teardown is the HMR
// dispose hook below, which prevents a Vite hot-update from stacking a
// duplicate persisting subscriber during dev (production uses /* @refresh
// reload */, so it never runs there).
const accountSubscription = accountManager.active$.subscribe(account => {
setAccount(account)
localStorage.setItem("caravel.accounts", JSON.stringify(accountManager.toJSON(true)))
if (account) {
localStorage.setItem("caravel.accounts.active", account.id)
} else {
localStorage.removeItem("caravel.accounts.active")
}
refetchIdentity()
// Lock billing reads until the new account's tenant is ensured, so they never
// fire against a not-yet-provisioned tenant during signup.
setBillingPubkey(undefined)
if (account)
void ensureSessionTenant().catch(e => {
console.error("Failed to ensure tenant", e)
setToastMessage(e instanceof Error ? e.message : "Failed to set up your billing account")
})
})
if (import.meta.hot) import.meta.hot.dispose(() => accountSubscription.unsubscribe())
})