diff --git a/go/cmd/orion/main.go b/go/cmd/orion/main.go index bbc9bd5..9993c0a 100644 --- a/go/cmd/orion/main.go +++ b/go/cmd/orion/main.go @@ -149,6 +149,9 @@ func main() { mux.Handle("POST /networks/{id}/billing/checkout-session", withAuth(h.CreateCheckoutSession)) mux.Handle("POST /networks/{id}/billing/portal-session", withAuth(h.CreatePortalSession)) + // Freemium usage (any network member) + mux.Handle("GET /networks/{id}/usage", withAuth(h.GetNetworkUsage)) + // Network Invitations mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork)) mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation)) diff --git a/go/cmd/particleprocessorworker/main.go b/go/cmd/particleprocessorworker/main.go index 72528b5..6642d42 100644 --- a/go/cmd/particleprocessorworker/main.go +++ b/go/cmd/particleprocessorworker/main.go @@ -6,8 +6,10 @@ import ( "log" "log/slog" "os" + "strings" "time" + "github.com/flowy-live/llink/internal/billing" "github.com/flowy-live/llink/internal/db" "github.com/flowy-live/llink/internal/depot" "github.com/flowy-live/llink/internal/particle" @@ -43,6 +45,7 @@ func main() { db.Init() defer db.Cleanup() processingRepo := particle.NewProcessingRepository(db.Pool()) + billingSvc := billing.NewServiceForWorker(db.Pool()) storageClient, err := storage.NewClient(ctx) if err != nil { @@ -104,6 +107,7 @@ func main() { updateParentLastChildCreatedAt(ctx, change.Doc) transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc) + recordFreemiumUsage(ctx, billingSvc, change.Doc) if err := processingRepo.MarkProcessed(ctx, particleID); err != nil { slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err) @@ -192,6 +196,59 @@ func toFirestoreTranscript(result *speech.TranscriptResult) particle.FirestoreTr } } +// recordFreemiumUsage bumps the network's daily message counter for non-container +// particles. Idempotent via the surrounding processed_particles guard: the worker +// only reaches this path on first-seen particles, so a crash/restart won't +// double-count. +func recordFreemiumUsage(ctx context.Context, billingSvc billing.Service, doc *firestore.DocumentSnapshot) { + rawType, err := doc.DataAt("type") + if err != nil { + slog.Error("failed to read particle type", "error", err, "particleID", doc.Ref.ID) + return + } + typeStr, ok := rawType.(string) + if !ok { + slog.Error("particle type is not a string", "particleID", doc.Ref.ID, "type", rawType) + return + } + particleType, err := particle.ParseParticleType(typeStr) + if err != nil { + slog.Error("invalid particle type", "error", err, "particleID", doc.Ref.ID) + return + } + // Containers (stream/folder) don't count as "messages" for the daily cap. + if particleType == particle.TypeStream || particleType == particle.TypeFolder { + return + } + + networkID, err := networkIDFromParticlePath(doc.Ref.Path) + if err != nil { + slog.Error("failed to derive network id", "error", err, "path", doc.Ref.Path) + return + } + + if err := billingSvc.IncrementDailyUsage(ctx, networkID, doc.CreateTime); err != nil { + slog.Error("failed to increment daily usage", "error", err, "networkID", networkID, "particleID", doc.Ref.ID) + } +} + +// networkIDFromParticlePath extracts the network id from a Firestore particle +// document path. Particles live at `networks/{network_id}/children/.../children/{id}` +// at arbitrary nesting depth, so the network id is always the second segment +// of the full doc path (which itself is rooted under the Firestore db path: +// `projects/.../documents/networks/{network_id}/...`). +func networkIDFromParticlePath(path string) (string, error) { + // doc.Ref.Path is the full resource path; find the "networks" collection + // and return the next segment. + segments := strings.Split(path, "/") + for i, seg := range segments { + if seg == "networks" && i+1 < len(segments) { + return segments[i+1], nil + } + } + return "", fmt.Errorf("no networks segment in path: %s", path) +} + // updateParentLastChildCreatedAt updates the parent stream's last_child_created_at // to the child's actual created_at timestamp, so it stays directly comparable with // playback markers (which also store child created_at values). diff --git a/go/internal/billing/noop.go b/go/internal/billing/noop.go index 764a2bd..2eabafc 100644 --- a/go/internal/billing/noop.go +++ b/go/internal/billing/noop.go @@ -3,6 +3,7 @@ package billing import ( "context" "errors" + "time" ) // Noop returns a billing service for binaries that depend on network.Service @@ -24,3 +25,9 @@ func (noopService) CreatePortalSession(context.Context, string) (string, error) } func (noopService) SyncSeats(context.Context, string, int) error { return nil } func (noopService) HandleWebhook(context.Context, []byte, string) error { return errNoopBilling } +func (noopService) GetUsage(context.Context, string) (*Usage, error) { + return nil, errNoopBilling +} +func (noopService) IncrementDailyUsage(context.Context, string, time.Time) error { + return errNoopBilling +} diff --git a/go/internal/billing/service.go b/go/internal/billing/service.go index c15635c..ca5a588 100644 --- a/go/internal/billing/service.go +++ b/go/internal/billing/service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stripe/stripe-go/v85" @@ -22,6 +23,14 @@ type Service interface { // No-op if the network has no active subscription. SyncSeats(ctx context.Context, networkID string, seats int) error HandleWebhook(ctx context.Context, payload []byte, signature string) error + + // GetUsage reports today's freemium quota state for a network. + // Pro networks get Limit=nil (unlimited); free networks get Limit=&FreemiumDailyLimit. + GetUsage(ctx context.Context, networkID string) (*Usage, error) + // IncrementDailyUsage is called by the particle processor worker for each + // qualifying particle (non-container). Idempotency is the caller's concern + // — the worker guards this via processed_particles. + IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error } var ( @@ -32,6 +41,7 @@ var ( type serviceImpl struct { cfg Config repo repository + usageRepo usageRepository priceMonthlyCents int64 priceAnnualCents int64 } @@ -58,11 +68,22 @@ func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, e return &serviceImpl{ cfg: cfg, repo: newRepository(pool), + usageRepo: newUsageRepository(pool), priceMonthlyCents: monthly.UnitAmount, priceAnnualCents: annual.UnitAmount, }, nil } +// NewServiceForWorker builds a minimal billing Service suitable for the +// particle processor worker: only the usage-tracking path is exercised, so +// we skip Stripe client setup (no API key required). +func NewServiceForWorker(pool *pgxpool.Pool) Service { + return &serviceImpl{ + usageRepo: newUsageRepository(pool), + repo: newRepository(pool), + } +} + func (s *serviceImpl) GetStatus(ctx context.Context, networkID string) (*Status, error) { sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID) if err != nil && !errors.Is(err, errNotFound) { @@ -208,6 +229,59 @@ func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int return nil } +func (s *serviceImpl) IncrementDailyUsage(ctx context.Context, networkID string, at time.Time) error { + return s.usageRepo.incrementDaily(ctx, networkID, at) +} + +func (s *serviceImpl) GetUsage(ctx context.Context, networkID string) (*Usage, error) { + now := time.Now() + used, err := s.usageRepo.getDaily(ctx, networkID, now) + if err != nil { + return nil, fmt.Errorf("read daily usage: %w", err) + } + + plan, err := s.resolvePlan(ctx, networkID) + if err != nil { + return nil, err + } + + u := &Usage{ + Plan: plan, + Used: used, + ResetAt: nextUTCMidnight(now), + } + if plan == PlanFree { + limit := FreemiumDailyLimit + u.Limit = &limit + } + return u, nil +} + +// resolvePlan is a lightweight read: it infers free/pro from the local +// subscription row without hitting Stripe, so it is safe to call from the +// particle processor worker (no Stripe client required). +func (s *serviceImpl) resolvePlan(ctx context.Context, networkID string) (Plan, error) { + sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID) + if errors.Is(err, errNotFound) { + return PlanFree, nil + } + if err != nil { + return "", err + } + switch stripe.SubscriptionStatus(sub.Status) { + case stripe.SubscriptionStatusActive, + stripe.SubscriptionStatusTrialing, + stripe.SubscriptionStatusPastDue: + return PlanPro, nil + } + return PlanFree, nil +} + +func nextUTCMidnight(now time.Time) time.Time { + utc := now.UTC() + return time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour) +} + func (s *serviceImpl) ensureStripeCustomer(ctx context.Context, p CheckoutParams) (string, error) { existing, err := s.repo.getStripeCustomerID(ctx, p.NetworkID) if err == nil { diff --git a/go/internal/billing/usage_models.go b/go/internal/billing/usage_models.go new file mode 100644 index 0000000..d1c588a --- /dev/null +++ b/go/internal/billing/usage_models.go @@ -0,0 +1,15 @@ +package billing + +import "time" + +// FreemiumDailyLimit is the per-network daily cap on non-container +// particles for networks on the free plan. +const FreemiumDailyLimit = 50 + +// Usage describes a network's current freemium quota state for today. +type Usage struct { + Plan Plan `json:"plan"` + Used int `json:"used"` + Limit *int `json:"limit"` // nil = unlimited (pro) + ResetAt time.Time `json:"reset_at"` +} diff --git a/go/internal/billing/usage_repository.go b/go/internal/billing/usage_repository.go new file mode 100644 index 0000000..79364a4 --- /dev/null +++ b/go/internal/billing/usage_repository.go @@ -0,0 +1,49 @@ +package billing + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type usageRepository interface { + incrementDaily(ctx context.Context, networkID string, at time.Time) error + getDaily(ctx context.Context, networkID string, at time.Time) (int, error) +} + +type usageRepositoryImpl struct { + pool *pgxpool.Pool +} + +func newUsageRepository(pool *pgxpool.Pool) usageRepository { + return &usageRepositoryImpl{pool: pool} +} + +func (r *usageRepositoryImpl) incrementDaily(ctx context.Context, networkID string, at time.Time) error { + _, err := r.pool.Exec(ctx, ` + INSERT INTO network_message_usage (network_id, usage_date, message_count, updated_at) + VALUES ($1, ($2 AT TIME ZONE 'UTC')::date, 1, NOW()) + ON CONFLICT (network_id, usage_date) DO UPDATE + SET message_count = network_message_usage.message_count + 1, + updated_at = NOW() + `, networkID, at) + return err +} + +func (r *usageRepositoryImpl) getDaily(ctx context.Context, networkID string, at time.Time) (int, error) { + var count int + err := r.pool.QueryRow(ctx, ` + SELECT message_count FROM network_message_usage + WHERE network_id = $1 AND usage_date = ($2 AT TIME ZONE 'UTC')::date + `, networkID, at).Scan(&count) + if errors.Is(err, pgx.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, err + } + return count, nil +} diff --git a/go/internal/handler/billing.go b/go/internal/handler/billing.go index 6ef2890..2d17206 100644 --- a/go/internal/handler/billing.go +++ b/go/internal/handler/billing.go @@ -25,6 +25,46 @@ type PortalSessionResponse struct { URL string `json:"url"` } +// GetNetworkUsage returns the freemium quota state for the authenticated +// caller's current network: how many messages they've used today, the daily +// limit (null for pro), and when the counter resets. +// +// Authorization: any network member may read (not admin-only) since the UI +// surfaces this to every sender. +func (h *Handler) GetNetworkUsage(w http.ResponseWriter, r *http.Request) { + humanId, ok := middleware.HumanIdFromContext(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + networkID := r.PathValue("id") + if networkID == "" { + http.Error(w, "network id is required", http.StatusBadRequest) + return + } + + isMember, err := h.networkSvc.IsMember(r.Context(), networkID, humanId) + if err != nil { + slog.Error("failed to check network membership", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + if !isMember { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + + usage, err := h.billingSvc.GetUsage(r.Context(), networkID) + if err != nil { + slog.Error("failed to get network usage", "error", err, "network_id", networkID) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + writeJSON(w, usage) +} + func (h *Handler) GetNetworkBilling(w http.ResponseWriter, r *http.Request) { net, _, ok := h.loadNetworkForAdmin(w, r) if !ok { diff --git a/go/migrations/000014_network_message_usage.down.sql b/go/migrations/000014_network_message_usage.down.sql new file mode 100644 index 0000000..2364ec5 --- /dev/null +++ b/go/migrations/000014_network_message_usage.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS network_message_usage; + +COMMIT; diff --git a/go/migrations/000014_network_message_usage.up.sql b/go/migrations/000014_network_message_usage.up.sql new file mode 100644 index 0000000..a6d082e --- /dev/null +++ b/go/migrations/000014_network_message_usage.up.sql @@ -0,0 +1,11 @@ +BEGIN; + +CREATE TABLE network_message_usage ( + network_id TEXT NOT NULL REFERENCES networks(id) ON DELETE CASCADE, + usage_date DATE NOT NULL, + message_count INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (network_id, usage_date) +); + +COMMIT; diff --git a/js/src/api/client.ts b/js/src/api/client.ts index 2a3c31f..d1671e0 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -10,6 +10,7 @@ import { ListInvitationsResponseSchema, ListNetworksResponseSchema, NetworkSchema, + NetworkUsageSchema, PortalSessionResponseSchema, PrepareUploadResponseSchema, SignInResponseSchema, @@ -247,6 +248,14 @@ class ApiClient { `/networks/${networkId}/billing/portal-session`, ); } + + async getNetworkUsage(networkId: string) { + return this.request( + NetworkUsageSchema, + "GET", + `/networks/${networkId}/usage`, + ); + } } export const apiClient = new ApiClient({ diff --git a/js/src/api/types.ts b/js/src/api/types.ts index ace151f..d1b1a60 100644 --- a/js/src/api/types.ts +++ b/js/src/api/types.ts @@ -304,3 +304,11 @@ export const PortalSessionResponseSchema = z.object({ url: z.string().url(), }); export type PortalSessionResponse = z.infer; + +export const NetworkUsageSchema = z.object({ + plan: NetworkPlanSchema, + used: z.number().int().nonnegative(), + limit: z.number().int().nonnegative().nullable(), + reset_at: z.coerce.date(), +}); +export type NetworkUsage = z.infer; diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 8713b5d..d361902 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; import { toast } from "sonner"; import { useAuthStore } from "@/stores/auth-store"; -import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle"; +import { QuotaExceededError, useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle"; +import { isUsageExhausted, useInvalidateNetworkUsage, useNetworkUsage } from "@/hooks/use-network-usage"; import { useRecorder } from "@/features/compose/use-recorder"; import { useScreenRecorder } from "@/features/compose/use-screen-recorder"; import { particlePath, parseParticlePath } from "@/lib/particle-path"; @@ -70,12 +71,17 @@ export function ComposeOverlay({ const userId = useAuthStore((s) => s.user?.id); const createParticle = useCreateParticle(); const createStream = useCreateStreamParticle(); + const { data: usage } = useNetworkUsage(networkId); + const invalidateUsage = useInvalidateNetworkUsage(); + const quotaExhausted = isUsageExhausted(usage); // Refs for synchronous reads in keyboard handlers const stepRef = useRef(step); const recordStartRef = useRef(0); const disabledRef = useRef(disabled); disabledRef.current = disabled; + const quotaExhaustedRef = useRef(quotaExhausted); + quotaExhaustedRef.current = quotaExhausted; const recordingSourceRef = useRef(recordingSource); recordingSourceRef.current = recordingSource; @@ -88,7 +94,12 @@ export function ComposeOverlay({ useEffect(() => { onActiveChange?.(step !== "idle"); onStepChange?.(step); - }, [step, onActiveChange, onStepChange]); + // Refresh quota when the overlay activates — user is about to send, so + // we want the most accurate count before the client-side gate kicks in. + if (step !== "idle") { + void invalidateUsage(networkId); + } + }, [step, onActiveChange, onStepChange, invalidateUsage, networkId]); const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => { for (const a of items) { @@ -334,12 +345,25 @@ export function ComposeOverlay({ ], ); + const handleQuotaError = useCallback((err: unknown): boolean => { + if (err instanceof QuotaExceededError) { + toast.error("Daily message limit reached. Upgrade to Pro to keep sending."); + cancel(); + return true; + } + return false; + }, [cancel]); + // Reply mode: create particle directly under targetPath const onSubmitReply = useEffectEvent(async () => { if (!targetPath || !userId || stepRef.current === "submitting") return; setStepSync("submitting"); - await createChildParticle(targetPath); - cancel(); + try { + await createChildParticle(targetPath); + cancel(); + } catch (err) { + if (!handleQuotaError(err)) throw err; + } }); // New stream mode: create stream + first child @@ -348,21 +372,25 @@ export function ComposeOverlay({ if (!userId || stepRef.current === "submitting") return; setStepSync("submitting"); - const streamId = await createStream.mutateAsync({ - networkId, - properties: { - name: streamName, - }, - createdByHumanId: userId, - visibleTo, - }); + try { + const streamId = await createStream.mutateAsync({ + networkId, + properties: { + name: streamName, + }, + createdByHumanId: userId, + visibleTo, + }); - const streamChildrenPath = particlePath(networkId, [streamId]); - await createChildParticle(streamChildrenPath); + const streamChildrenPath = particlePath(networkId, [streamId]); + await createChildParticle(streamChildrenPath); - cancel(); + cancel(); + } catch (err) { + if (!handleQuotaError(err)) throw err; + } }, - [networkId, userId, createParticle, createChildParticle, cancel], + [networkId, userId, createStream, createChildParticle, cancel, handleQuotaError], ); // --- Keyboard handling --- @@ -397,6 +425,13 @@ export function ComposeOverlay({ } break; } + if (quotaExhaustedRef.current) { + if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T" || e.key === "s" || e.key === "S") { + e.preventDefault(); + toast.info("Daily message limit reached. Upgrade to Pro to keep sending."); + } + break; + } if (e.key === "`" && !e.repeat) { e.preventDefault(); recordStartRef.current = Date.now(); diff --git a/js/src/features/compose/compose-quota-indicator.tsx b/js/src/features/compose/compose-quota-indicator.tsx new file mode 100644 index 0000000..567ec93 --- /dev/null +++ b/js/src/features/compose/compose-quota-indicator.tsx @@ -0,0 +1,94 @@ +import { useNavigate } from "react-router-dom"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; +import { useNetworkUsage } from "@/hooks/use-network-usage"; +import { useIsNetworkAdmin, useNetwork } from "@/hooks/use-networks"; + +interface ComposeQuotaIndicatorProps { + networkId: string; +} + +const SHOW_PROGRESS_AT_FRACTION = 0.7; + +/** + * Surfaces freemium quota state near compose: + * - Nothing below 70% used (avoid nagging). + * - A subtle progress pill between 70% and the limit. + * - A locked banner with an upgrade CTA once the limit is hit. + * + * Pro networks and any network still loading usage render nothing. + */ +export function ComposeQuotaIndicator({ networkId }: ComposeQuotaIndicatorProps) { + const navigate = useNavigate(); + const { data: usage } = useNetworkUsage(networkId); + const isAdmin = useIsNetworkAdmin(networkId); + const network = useNetwork(networkId); + + if (!usage || usage.limit == null) return null; + + const fraction = usage.used / usage.limit; + const exhausted = usage.used >= usage.limit; + + if (exhausted) { + return ( +
+
+ {isAdmin + ? `You've reached today's ${usage.limit}-message limit` + : `This network reached today's ${usage.limit}-message limit`} +
+
+ Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)}) +
+ {isAdmin ? ( + + ) : ( +
+ Ask{" "} + + {network?.admin_human.email_prefix ?? "your admin"} + {" "} + to upgrade to Pro +
+ )} +
+ ); + } + + if (fraction < SHOW_PROGRESS_AT_FRACTION) return null; + + return ( +
+ + {usage.used}/{usage.limit} today + + +
+ ); +} + +function formatResetRelative(resetAt: Date): string { + const now = new Date(); + const diffMs = resetAt.getTime() - now.getTime(); + const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000))); + if (hours < 1) return "soon"; + if (hours === 1) return "in 1 hour"; + return `in ${hours} hours`; +} + +function formatResetAbsolute(resetAt: Date): string { + // Shows the user their local wall-clock time for the UTC-midnight reset, + // so a user in UTC-8 sees "4:00 PM" instead of a relative hint alone. + return resetAt.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} diff --git a/js/src/features/network-billing.tsx b/js/src/features/network-billing.tsx index 60eed78..42df501 100644 --- a/js/src/features/network-billing.tsx +++ b/js/src/features/network-billing.tsx @@ -13,6 +13,8 @@ import { useCreatePortalSession, useNetworkBilling, } from "@/hooks/use-billing"; +import { useNetworkUsage } from "@/hooks/use-network-usage"; +import { useIsNetworkAdmin } from "@/hooks/use-networks"; import type { BillingCadence, BillingStatus } from "@/api/types"; function formatCents(cents: number): string { @@ -91,6 +93,70 @@ function CadenceOption({ ); } +function formatResetLocal(resetAt: Date): string { + const now = new Date(); + const diffMs = resetAt.getTime() - now.getTime(); + const hours = Math.max(0, Math.round(diffMs / (60 * 60 * 1000))); + const absolute = resetAt.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + if (hours < 1) return `soon (${absolute})`; + if (hours === 1) return `in 1 hour (${absolute})`; + return `in ${hours} hours (${absolute})`; +} + +/** + * Read-only plan + quota summary, sourced from `/usage` (member-accessible). + * The `/billing` endpoint is admin-gated, so we can't use it for the + * everyone-visible summary. + */ +function PlanSummary({ networkId }: { networkId: string }) { + const { data: usage } = useNetworkUsage(networkId); + + if (!usage) return null; + + const isPro = usage.plan === "pro"; + + return ( + <> + + {isPro ? "Llink Pro" : "Llink Free"} + + {isPro ? "Pro" : "Free"} + + + } + /> + {!isPro && ( + <> + + + + {usage.used} / {usage.limit} + + + Resets {formatResetLocal(usage.reset_at)} + + + ) : ( + + ) + } + /> + + )} + + ); +} + function FreeBilling({ networkId, billing, @@ -115,16 +181,6 @@ function FreeBilling({ return ( <> - - Llink Free - Free - - } - /> - setCadence(v as BillingCadence)} @@ -201,21 +257,15 @@ function ProBilling({ )} - Llink Pro + {`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`} - Pro } /> - - {renewal && ( <> @@ -243,7 +293,29 @@ function ProBilling({ ); } +/** + * Unified billing section. Shows the plan + usage summary to every member, + * and the admin-only management controls (upgrade / portal) below. + * + * `/billing` is admin-gated, so the management controls are the only part + * that depends on it — members rely on `/usage` for the summary. + */ export function BillingSection({ networkId }: { networkId: string }) { + const isAdmin = useIsNetworkAdmin(networkId); + return ( + <> + + {isAdmin && ( + <> + + + + )} + + ); +} + +function AdminBillingControls({ networkId }: { networkId: string }) { const { data: billing, isLoading, error } = useNetworkBilling(networkId); if (isLoading || !billing) { diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index f008dd5..a1d4af8 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -5,6 +5,7 @@ import { particlePath } from "@/lib/particle-path"; import { ParticleListView } from "@/features/particles/particle-list-view"; import { VideoAudioToggle } from "@/components/video-audio-toggle"; import { ComposeOverlay } from "./compose/compose-overlay"; +import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useStreamParticles } from "@/hooks/use-stream-particles"; import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav"; @@ -75,6 +76,11 @@ export default function NetworkRoot() { + {!composeActive && ( +
+ +
+ )}
diff --git a/js/src/features/network-settings.tsx b/js/src/features/network-settings.tsx index 7418f73..e98d30b 100644 --- a/js/src/features/network-settings.tsx +++ b/js/src/features/network-settings.tsx @@ -177,6 +177,12 @@ export default function NetworkSettingsPage() {
+ + + + + + {network?.humans.map((human, index) => (
@@ -220,12 +226,6 @@ export default function NetworkSettingsPage() {

)} - - - - - - )} diff --git a/js/src/hooks/use-create-particle.ts b/js/src/hooks/use-create-particle.ts index 66e8def..b49767e 100644 --- a/js/src/hooks/use-create-particle.ts +++ b/js/src/hooks/use-create-particle.ts @@ -1,7 +1,26 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { createParticle, createStreamParticle } from "@/lib/firestore-particles"; -import type { ParticleType, ParticlePropertiesMap } from "@/api/types"; -import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path"; +import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types"; +import { parseParticlePath, particlePath, ParticlePath, toFirestoreChildrenPath } from "@/lib/particle-path"; +import { + isUsageExhausted, + networkUsageQueryKey, + useBumpNetworkUsage, + useInvalidateNetworkUsage, +} from "./use-network-usage"; + +/** + * Thrown when a free-plan network attempts to create a non-container particle + * after hitting its daily message limit. Callers should surface an upgrade + * prompt; compose UI should also disable triggers proactively via + * `useNetworkUsage` rather than relying on this throw. + */ +export class QuotaExceededError extends Error { + constructor(public readonly networkId: string) { + super("Daily message limit reached"); + this.name = "QuotaExceededError"; + } +} interface CreateParticleParams { // Path to which the new particle will be added as a child @@ -12,16 +31,37 @@ interface CreateParticleParams { } export function useCreateParticle() { + const qc = useQueryClient(); + const bumpUsage = useBumpNetworkUsage(); + const invalidateUsage = useInvalidateNetworkUsage(); + return useMutation({ mutationFn: async (params: CreateParticleParams) => { + const { networkId } = parseParticlePath(params.path); + + // Containers aren't counted server-side, so we don't block them. + if (!CONTAINER_TYPES.has(params.type)) { + const cached = qc.getQueryData(networkUsageQueryKey(networkId)); + if (isUsageExhausted(cached)) { + throw new QuotaExceededError(networkId); + } + } + const collectionPath = toFirestoreChildrenPath(params.path); - return await createParticle( + const result = await createParticle( collectionPath, params.type, params.properties, params.createdByHumanId, ); - } + + if (!CONTAINER_TYPES.has(params.type)) { + bumpUsage(networkId); + void invalidateUsage(networkId); + } + + return result; + }, }); } diff --git a/js/src/hooks/use-network-usage.ts b/js/src/hooks/use-network-usage.ts new file mode 100644 index 0000000..e88ab0b --- /dev/null +++ b/js/src/hooks/use-network-usage.ts @@ -0,0 +1,57 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { apiClient } from "@/api/client"; +import type { NetworkUsage } from "@/api/types"; + +export const networkUsageQueryKey = (networkId: string | undefined) => + ["network-usage", networkId] as const; + +export function useNetworkUsage(networkId: string | undefined) { + return useQuery({ + queryKey: networkUsageQueryKey(networkId), + queryFn: () => apiClient.getNetworkUsage(networkId!), + enabled: !!networkId, + // Refetch whenever a consumer mounts (billing settings, compose indicator) + // so users land on fresh quota state without listener wiring. + refetchOnMount: "always", + refetchOnWindowFocus: true, + }); +} + +/** + * Returns a callback that invalidates the usage query for a network. + * Callers: own-send success path, inbound-particle listener. + */ +export function useInvalidateNetworkUsage() { + const qc = useQueryClient(); + return useCallback( + (networkId: string) => + qc.invalidateQueries({ queryKey: networkUsageQueryKey(networkId) }), + [qc], + ); +} + +/** + * Optimistic bump of the cached `used` count. The worker-written truth is + * reconciled on the next invalidation/refetch. + */ +export function useBumpNetworkUsage() { + const qc = useQueryClient(); + return useCallback( + (networkId: string) => { + qc.setQueryData(networkUsageQueryKey(networkId), (prev) => + prev ? { ...prev, used: prev.used + 1 } : prev, + ); + }, + [qc], + ); +} + +/** + * True iff the network is on the free plan and has exhausted today's quota. + */ +export function isUsageExhausted(usage: NetworkUsage | undefined): boolean { + if (!usage) return false; + if (usage.limit == null) return false; + return usage.used >= usage.limit; +} diff --git a/js/src/hooks/use-networks.ts b/js/src/hooks/use-networks.ts index 7e2174d..1608023 100644 --- a/js/src/hooks/use-networks.ts +++ b/js/src/hooks/use-networks.ts @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { apiClient } from "@/api/client"; +import { useAuthStore } from "@/stores/auth-store"; export function useNetworks() { return useQuery({ @@ -12,3 +13,10 @@ export function useNetwork(networkId: string) { const { data: networks } = useNetworks(); return networks?.find((n) => n.id === networkId) || null; } + +export function useIsNetworkAdmin(networkId: string): boolean { + const network = useNetwork(networkId); + const userId = useAuthStore((s) => s.user?.id); + if (!network || !userId) return false; + return network.admin_human.id === userId; +} diff --git a/js/src/hooks/use-stream-playback.ts b/js/src/hooks/use-stream-playback.ts index 7cb8758..5efb5bc 100644 --- a/js/src/hooks/use-stream-playback.ts +++ b/js/src/hooks/use-stream-playback.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, us import { useAuthStore } from "@/stores/auth-store"; import type { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; -import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; +import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; // --- Playback reducer (ID-based) ---