feat: implement freemium restrictions
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS network_message_usage;
|
||||
|
||||
COMMIT;
|
||||
@@ -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;
|
||||
@@ -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({
|
||||
|
||||
@@ -304,3 +304,11 @@ export const PortalSessionResponseSchema = z.object({
|
||||
url: z.string().url(),
|
||||
});
|
||||
export type PortalSessionResponse = z.infer<typeof PortalSessionResponseSchema>;
|
||||
|
||||
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<typeof NetworkUsageSchema>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<div className="pointer-events-auto flex max-w-md flex-col items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 text-center shadow-lg backdrop-blur">
|
||||
<div className="text-sm font-medium">
|
||||
{isAdmin
|
||||
? `You've reached today's ${usage.limit}-message limit`
|
||||
: `This network reached today's ${usage.limit}-message limit`}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Resets {formatResetRelative(usage.reset_at)} ({formatResetAbsolute(usage.reset_at)})
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/${networkId}/settings?section=billing`)}
|
||||
>
|
||||
Upgrade to Pro
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Ask{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{network?.admin_human.email_prefix ?? "your admin"}
|
||||
</span>{" "}
|
||||
to upgrade to Pro
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fraction < SHOW_PROGRESS_AT_FRACTION) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-full border border-border bg-background/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur"
|
||||
title={`Resets ${formatResetRelative(usage.reset_at)} at ${formatResetAbsolute(usage.reset_at)}`}
|
||||
>
|
||||
<span className="tabular-nums">
|
||||
{usage.used}/{usage.limit} today
|
||||
</span>
|
||||
<Progress value={fraction * 100} className="h-1 w-24" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Plan"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{isPro ? "Llink Pro" : "Llink Free"}</span>
|
||||
<Badge variant={isPro ? "default" : "secondary"}>
|
||||
{isPro ? "Pro" : "Free"}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{!isPro && (
|
||||
<>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow
|
||||
label="Today's messages"
|
||||
value={
|
||||
usage && usage.limit != null ? (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="tabular-nums">
|
||||
{usage.used} / {usage.limit}
|
||||
</span>
|
||||
<Muted className="text-xs">
|
||||
Resets {formatResetLocal(usage.reset_at)}
|
||||
</Muted>
|
||||
</div>
|
||||
) : (
|
||||
<Muted className="text-sm">—</Muted>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FreeBilling({
|
||||
networkId,
|
||||
billing,
|
||||
@@ -115,16 +181,6 @@ function FreeBilling({
|
||||
|
||||
return (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Plan"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Llink Free</span>
|
||||
<Badge variant="secondary">Free</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<RadioGroup
|
||||
value={cadence}
|
||||
onValueChange={(v) => setCadence(v as BillingCadence)}
|
||||
@@ -201,21 +257,15 @@ function ProBilling({
|
||||
)}
|
||||
|
||||
<InfoRow
|
||||
label="Plan"
|
||||
label="Billing"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Llink Pro</span>
|
||||
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
|
||||
<PlanStatusBadge status={billing.plan_status} />
|
||||
<Badge>Pro</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow
|
||||
label="Billing"
|
||||
value={`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}
|
||||
/>
|
||||
<Separator className="mx-4" />
|
||||
<InfoRow label="Seats" value={billing.seats} />
|
||||
{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 (
|
||||
<>
|
||||
<PlanSummary networkId={networkId} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Separator className="mx-4" />
|
||||
<AdminBillingControls networkId={networkId} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminBillingControls({ networkId }: { networkId: string }) {
|
||||
const { data: billing, isLoading, error } = useNetworkBilling(networkId);
|
||||
|
||||
if (isLoading || !billing) {
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
||||
{!composeActive && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-16 z-20 flex justify-center px-3">
|
||||
<ComposeQuotaIndicator networkId={networkId!} />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||
<div className="pointer-events-auto">
|
||||
<NetworkRootControls />
|
||||
|
||||
@@ -177,6 +177,12 @@ export default function NetworkSettingsPage() {
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<SettingsGroup title="Billing">
|
||||
<BillingSection networkId={networkId!} />
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Members">
|
||||
{network?.humans.map((human, index) => (
|
||||
<div key={human.id}>
|
||||
@@ -220,12 +226,6 @@ export default function NetworkSettingsPage() {
|
||||
</p>
|
||||
)}
|
||||
</SettingsGroup>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
|
||||
<SettingsGroup title="Billing">
|
||||
<BillingSection networkId={networkId!} />
|
||||
</SettingsGroup>
|
||||
</>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -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<T extends ParticleType = ParticleType> {
|
||||
// Path to which the new particle will be added as a child
|
||||
@@ -12,16 +31,37 @@ interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||
}
|
||||
|
||||
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<NetworkUsage>(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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<NetworkUsage>(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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user