implement paywall #161
+1
-1
@@ -38,7 +38,7 @@ PROD_REPO := us-west2-docker.pkg.dev/flowy-prod-440017/deployments
|
|||||||
.PHONY: migrate-dev
|
.PHONY: migrate-dev
|
||||||
migrate-dev:
|
migrate-dev:
|
||||||
kubectl delete job migrations --ignore-not-found --context=dev
|
kubectl delete job migrations --ignore-not-found --context=dev
|
||||||
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev
|
SKAFFOLD_DEFAULT_REPO=$(DEV_REPO) skaffold run -p migrations --kube-context dev --tail
|
||||||
|
|
||||||
.PHONY: migrate-prod
|
.PHONY: migrate-prod
|
||||||
migrate-prod:
|
migrate-prod:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"cloud.google.com/go/firestore"
|
"cloud.google.com/go/firestore"
|
||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
pbpusher "github.com/flowy-live/llink/genproto/llink/pusher"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/human"
|
"github.com/flowy-live/llink/internal/human"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
@@ -67,7 +68,7 @@ func main() {
|
|||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
humanSvc := human.NewService(db.Pool())
|
humanSvc := human.NewService(db.Pool())
|
||||||
networkSvc := network.NewService(db.Pool(), aeroSvc)
|
networkSvc := network.NewService(db.Pool(), aeroSvc, billing.Noop())
|
||||||
|
|
||||||
slog.Info("starting email notification cycle")
|
slog.Info("starting email notification cycle")
|
||||||
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
|
if err := runNotificationCycle(ctx, firestoreClient, aeroSvc, pusherSvc, humanSvc, networkSvc); err != nil {
|
||||||
|
|||||||
+26
-9
@@ -12,6 +12,7 @@ import (
|
|||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
"github.com/flowy-live/llink/internal"
|
"github.com/flowy-live/llink/internal"
|
||||||
"github.com/flowy-live/llink/internal/auth"
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
"github.com/flowy-live/llink/internal/handler"
|
"github.com/flowy-live/llink/internal/handler"
|
||||||
@@ -35,14 +36,11 @@ func main() {
|
|||||||
port := utils.MustGetEnv("PORT")
|
port := utils.MustGetEnv("PORT")
|
||||||
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
|
gcsBucket := utils.MustGetEnv("GCS_BUCKET")
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
db.Init()
|
db.Init()
|
||||||
defer db.Cleanup()
|
defer db.Cleanup()
|
||||||
|
|
||||||
// Initialize Redis for auth
|
|
||||||
redisClient := redisForAuth()
|
redisClient := redisForAuth()
|
||||||
|
|
||||||
// Initialize GCS client
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
storageClient, err := storage.NewClient(ctx)
|
storageClient, err := storage.NewClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,10 +62,23 @@ func main() {
|
|||||||
defer aeroServer.Close()
|
defer aeroServer.Close()
|
||||||
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
|
aeroSvc := pbaero.NewPrimaryClient(aeroServer)
|
||||||
|
|
||||||
// Initialize services
|
|
||||||
authSvc := auth.NewAuthService(redisClient, aeroSvc)
|
authSvc := auth.NewAuthService(redisClient, aeroSvc)
|
||||||
humanSvc := human.NewService(db.Pool())
|
humanSvc := human.NewService(db.Pool())
|
||||||
networkSvc := network.NewService(db.Pool(), aeroSvc)
|
|
||||||
|
billingSvc, err := billing.NewService(ctx, db.Pool(), billing.Config{
|
||||||
|
SecretKey: utils.MustGetEnv("STRIPE_SECRET_KEY"),
|
||||||
|
WebhookSecret: utils.MustGetEnv("STRIPE_WEBHOOK_SECRET"),
|
||||||
|
PriceMonthlyID: utils.MustGetEnv("STRIPE_PRICE_PRO_MONTHLY"),
|
||||||
|
PriceAnnualID: utils.MustGetEnv("STRIPE_PRICE_PRO_ANNUAL"),
|
||||||
|
SuccessURL: utils.MustGetEnv("BILLING_SUCCESS_URL"),
|
||||||
|
CancelURL: utils.MustGetEnv("BILLING_CANCEL_URL"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to initialize billing service", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkSvc := network.NewService(db.Pool(), aeroSvc, billingSvc)
|
||||||
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
particleSvc := particle.NewService(db.Pool(), networkSvc)
|
||||||
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
depotSvc := depot.NewService(db.Pool(), storageClient, depot.Config{
|
||||||
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
GoogleServiceAccountEmail: utils.MustGetEnv("GOOGLE_SERVICE_ACCOUNT_EMAIL"),
|
||||||
@@ -76,7 +87,6 @@ func main() {
|
|||||||
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
waitlistSvc := waitlist.NewService(db.Pool(), aeroSvc)
|
||||||
livekitClient := livekit.NewClient()
|
livekitClient := livekit.NewClient()
|
||||||
|
|
||||||
// Initialize Firestore client (for webhook-driven updates)
|
|
||||||
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
gcpProject := utils.MustGetEnv("GCP_PROJECT")
|
||||||
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
firestoreClient, err := firestore.NewClient(ctx, gcpProject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,10 +95,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer firestoreClient.Close()
|
defer firestoreClient.Close()
|
||||||
|
|
||||||
// Initialize handler
|
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, billingSvc, livekitClient, firestoreClient)
|
||||||
h := handler.NewHandler(authSvc, humanSvc, networkSvc, particleSvc, depotSvc, waitlistSvc, livekitClient, firestoreClient)
|
|
||||||
|
|
||||||
// Helper to wrap handlers with auth middleware
|
|
||||||
withAuth := func(hf http.HandlerFunc) http.Handler {
|
withAuth := func(hf http.HandlerFunc) http.Handler {
|
||||||
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
return middleware.Auth(authSvc)(http.HandlerFunc(hf))
|
||||||
}
|
}
|
||||||
@@ -108,6 +116,7 @@ func main() {
|
|||||||
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
|
mux.HandleFunc("POST /auth/sign-in", h.SignIn)
|
||||||
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
|
mux.HandleFunc("POST /waitlist", h.AddToWaitlist)
|
||||||
mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook)
|
mux.HandleFunc("POST /livekit/webhook", h.HandleLivekitWebhook)
|
||||||
|
mux.HandleFunc("POST /webhooks/stripe", h.HandleStripeWebhook)
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Protected routes (auth required)
|
// Protected routes (auth required)
|
||||||
@@ -127,6 +136,14 @@ func main() {
|
|||||||
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
mux.Handle("POST /networks/{id}/members", withAuth(h.AddMembersToNetwork))
|
||||||
// mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
|
// mux.Handle("DELETE /networks/{id}/members/{humanId}", withAuth(h.RemoveMemberFromNetwork))
|
||||||
|
|
||||||
|
// Billing (network admin only; admin check happens inside each handler)
|
||||||
|
mux.Handle("GET /networks/{id}/billing", withAuth(h.GetNetworkBilling))
|
||||||
|
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
|
// Network Invitations
|
||||||
mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork))
|
mux.Handle("GET /networks/{id}/invitations", withAuth(h.ListInvitationsForNetwork))
|
||||||
mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation))
|
mux.Handle("DELETE /networks/{id}/invitations", withAuth(h.RevokeInvitation))
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
@@ -43,6 +45,7 @@ func main() {
|
|||||||
db.Init()
|
db.Init()
|
||||||
defer db.Cleanup()
|
defer db.Cleanup()
|
||||||
processingRepo := particle.NewProcessingRepository(db.Pool())
|
processingRepo := particle.NewProcessingRepository(db.Pool())
|
||||||
|
billingSvc := billing.NewServiceForWorker(db.Pool())
|
||||||
|
|
||||||
storageClient, err := storage.NewClient(ctx)
|
storageClient, err := storage.NewClient(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -104,6 +107,7 @@ func main() {
|
|||||||
|
|
||||||
updateParentLastChildCreatedAt(ctx, change.Doc)
|
updateParentLastChildCreatedAt(ctx, change.Doc)
|
||||||
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
transcribeMediaParticle(ctx, depotSvc, speechSvc, change.Doc)
|
||||||
|
recordFreemiumUsage(ctx, billingSvc, change.Doc)
|
||||||
|
|
||||||
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
if err := processingRepo.MarkProcessed(ctx, particleID); err != nil {
|
||||||
slog.Error("failed to mark particle as processed", "particleID", particleID, "error", err)
|
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
|
// updateParentLastChildCreatedAt updates the parent stream's last_child_created_at
|
||||||
// to the child's actual created_at timestamp, so it stays directly comparable with
|
// to the child's actual created_at timestamp, so it stays directly comparable with
|
||||||
// playback markers (which also store child created_at values).
|
// playback markers (which also store child created_at values).
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/flowy-live/llink/internal"
|
"github.com/flowy-live/llink/internal"
|
||||||
"github.com/flowy-live/llink/internal/auth"
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/db"
|
"github.com/flowy-live/llink/internal/db"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"github.com/flowy-live/llink/internal/pusher"
|
"github.com/flowy-live/llink/internal/pusher"
|
||||||
@@ -36,8 +37,8 @@ func main() {
|
|||||||
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher)
|
pusherRedis := internal.ConnectAndTestRedis(db.RedisDBPusher)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
authSvc := auth.NewAuthService(authRedis, nil) // nil aeroSvc — pusher only calls GetSession
|
authSvc := auth.NewAuthService(authRedis, nil) // nil aeroSvc — pusher only calls GetSession
|
||||||
networkSvc := network.NewService(db.Pool(), nil) // nil aeroSvc — pusher never calls InviteByEmail
|
networkSvc := network.NewService(db.Pool(), nil, billing.Noop()) // nil aeroSvc / noop billing — pusher never mutates membership
|
||||||
|
|
||||||
// Pod identity (use hostname in k8s, which is the pod name)
|
// Pod identity (use hostname in k8s, which is the pod name)
|
||||||
podID, err := os.Hostname()
|
podID, err := os.Hostname()
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ require (
|
|||||||
github.com/redis/go-redis/v9 v9.17.2
|
github.com/redis/go-redis/v9 v9.17.2
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
|
github.com/stripe/stripe-go/v85 v85.0.1
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0
|
github.com/testcontainers/testcontainers-go v0.40.0
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||||
go.jetify.com/typeid v1.3.0
|
go.jetify.com/typeid v1.3.0
|
||||||
go.uber.org/mock v0.6.0
|
go.uber.org/mock v0.6.0
|
||||||
google.golang.org/grpc v1.79.1
|
google.golang.org/grpc v1.79.1
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
|
nhooyr.io/websocket v1.8.17
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -176,5 +178,4 @@ require (
|
|||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
k8s.io/klog/v2 v2.110.1 // indirect
|
k8s.io/klog/v2 v2.110.1 // indirect
|
||||||
nhooyr.io/websocket v1.8.17 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -335,6 +335,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/stripe/stripe-go/v85 v85.0.1 h1:vlIo5VHrR9GkYneH5D9YGOPwNDRD6LW/THhtx9zNs6M=
|
||||||
|
github.com/stripe/stripe-go/v85 v85.0.1/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
|
||||||
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
|
||||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
SecretKey string
|
||||||
|
WebhookSecret string
|
||||||
|
PriceMonthlyID string
|
||||||
|
PriceAnnualID string
|
||||||
|
|
||||||
|
SuccessURL string
|
||||||
|
CancelURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) Validate() error {
|
||||||
|
required := map[string]string{
|
||||||
|
"SecretKey": c.SecretKey,
|
||||||
|
"WebhookSecret": c.WebhookSecret,
|
||||||
|
"PriceMonthlyID": c.PriceMonthlyID,
|
||||||
|
"PriceAnnualID": c.PriceAnnualID,
|
||||||
|
"SuccessURL": c.SuccessURL,
|
||||||
|
"CancelURL": c.CancelURL,
|
||||||
|
}
|
||||||
|
var missing []string
|
||||||
|
for name, value := range required {
|
||||||
|
if value == "" {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("billing config missing: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Cadence string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CadenceMonthly Cadence = "monthly"
|
||||||
|
CadenceAnnual Cadence = "annual"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c Cadence) IsValid() bool {
|
||||||
|
return c == CadenceMonthly || c == CadenceAnnual
|
||||||
|
}
|
||||||
|
|
||||||
|
type Plan string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PlanFree Plan = "free"
|
||||||
|
PlanPro Plan = "pro"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscription is the persisted projection of a Stripe subscription,
|
||||||
|
// reconciled on every webhook event.
|
||||||
|
type Subscription struct {
|
||||||
|
ID string
|
||||||
|
NetworkID string
|
||||||
|
StripeCustomerID string
|
||||||
|
Status string
|
||||||
|
PriceID string
|
||||||
|
Cadence Cadence
|
||||||
|
Quantity int
|
||||||
|
CancelAtPeriodEnd bool
|
||||||
|
CurrentPeriodStart time.Time
|
||||||
|
CurrentPeriodEnd time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Plan Plan `json:"plan"`
|
||||||
|
PlanStatus string `json:"plan_status"`
|
||||||
|
Cadence *Cadence `json:"cadence"`
|
||||||
|
Seats int `json:"seats"` // 0 on free plan; sub.Quantity on pro
|
||||||
|
CurrentPeriodEnd *time.Time `json:"current_period_end"`
|
||||||
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
||||||
|
PriceMonthlyCents int64 `json:"price_monthly_cents"`
|
||||||
|
PriceAnnualCents int64 `json:"price_annual_cents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckoutParams struct {
|
||||||
|
NetworkID string
|
||||||
|
AdminHumanID string
|
||||||
|
AdminEmail string
|
||||||
|
Cadence Cadence
|
||||||
|
Seats int
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Noop returns a billing service for binaries that depend on network.Service
|
||||||
|
// but never mutate membership (jobs, pusher). Stripe isn't configured.
|
||||||
|
func Noop() Service { return noopService{} }
|
||||||
|
|
||||||
|
type noopService struct{}
|
||||||
|
|
||||||
|
var errNoopBilling = errors.New("billing: not configured in this process")
|
||||||
|
|
||||||
|
func (noopService) GetStatus(context.Context, string) (*Status, error) {
|
||||||
|
return nil, errNoopBilling
|
||||||
|
}
|
||||||
|
func (noopService) CreateCheckoutSession(context.Context, CheckoutParams) (string, error) {
|
||||||
|
return "", errNoopBilling
|
||||||
|
}
|
||||||
|
func (noopService) CreatePortalSession(context.Context, string) (string, error) {
|
||||||
|
return "", errNoopBilling
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
type repository interface {
|
||||||
|
getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error)
|
||||||
|
upsertSubscription(ctx context.Context, sub *Subscription) error
|
||||||
|
deleteSubscriptionByID(ctx context.Context, subscriptionID string) error
|
||||||
|
|
||||||
|
getStripeCustomerID(ctx context.Context, networkID string) (string, error)
|
||||||
|
setStripeCustomerID(ctx context.Context, networkID, customerID string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type repositoryImpl struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRepository(pool *pgxpool.Pool) repository {
|
||||||
|
return &repositoryImpl{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptionColumns = `id, network_id, stripe_customer_id, status, price_id, cadence, quantity, cancel_at_period_end, current_period_start, current_period_end, created_at, updated_at`
|
||||||
|
|
||||||
|
func scanSubscription(row pgx.Row, s *Subscription) error {
|
||||||
|
return row.Scan(
|
||||||
|
&s.ID, &s.NetworkID, &s.StripeCustomerID, &s.Status, &s.PriceID,
|
||||||
|
&s.Cadence, &s.Quantity, &s.CancelAtPeriodEnd,
|
||||||
|
&s.CurrentPeriodStart, &s.CurrentPeriodEnd,
|
||||||
|
&s.CreatedAt, &s.UpdatedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) getSubscriptionByNetworkID(ctx context.Context, networkID string) (*Subscription, error) {
|
||||||
|
var s Subscription
|
||||||
|
err := scanSubscription(
|
||||||
|
r.pool.QueryRow(ctx,
|
||||||
|
`SELECT `+subscriptionColumns+` FROM network_subscriptions WHERE network_id = $1`,
|
||||||
|
networkID,
|
||||||
|
),
|
||||||
|
&s,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, errNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) upsertSubscription(ctx context.Context, sub *Subscription) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO network_subscriptions (
|
||||||
|
id, network_id, stripe_customer_id, status, price_id, cadence,
|
||||||
|
quantity, cancel_at_period_end, current_period_start, current_period_end
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
stripe_customer_id = EXCLUDED.stripe_customer_id,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
price_id = EXCLUDED.price_id,
|
||||||
|
cadence = EXCLUDED.cadence,
|
||||||
|
quantity = EXCLUDED.quantity,
|
||||||
|
cancel_at_period_end = EXCLUDED.cancel_at_period_end,
|
||||||
|
current_period_start = EXCLUDED.current_period_start,
|
||||||
|
current_period_end = EXCLUDED.current_period_end,
|
||||||
|
updated_at = NOW()
|
||||||
|
`,
|
||||||
|
sub.ID, sub.NetworkID, sub.StripeCustomerID, sub.Status, sub.PriceID, sub.Cadence,
|
||||||
|
sub.Quantity, sub.CancelAtPeriodEnd, sub.CurrentPeriodStart, sub.CurrentPeriodEnd,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) deleteSubscriptionByID(ctx context.Context, subscriptionID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx,
|
||||||
|
`DELETE FROM network_subscriptions WHERE id = $1`,
|
||||||
|
subscriptionID,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) getStripeCustomerID(ctx context.Context, networkID string) (string, error) {
|
||||||
|
var customerID string
|
||||||
|
err := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT stripe_customer_id FROM network_stripe_customers WHERE network_id = $1`,
|
||||||
|
networkID,
|
||||||
|
).Scan(&customerID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", errNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return customerID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) setStripeCustomerID(ctx context.Context, networkID, customerID string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO network_stripe_customers (network_id, stripe_customer_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (network_id) DO NOTHING
|
||||||
|
`, networkID, customerID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stripe/stripe-go/v85"
|
||||||
|
billingportalsession "github.com/stripe/stripe-go/v85/billingportal/session"
|
||||||
|
checkoutsession "github.com/stripe/stripe-go/v85/checkout/session"
|
||||||
|
stripecustomer "github.com/stripe/stripe-go/v85/customer"
|
||||||
|
stripeprice "github.com/stripe/stripe-go/v85/price"
|
||||||
|
stripesub "github.com/stripe/stripe-go/v85/subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
GetStatus(ctx context.Context, networkID string) (*Status, error)
|
||||||
|
CreateCheckoutSession(ctx context.Context, p CheckoutParams) (url string, err error)
|
||||||
|
CreatePortalSession(ctx context.Context, networkID string) (url string, err error)
|
||||||
|
// SyncSeats updates the Stripe subscription quantity with proration.
|
||||||
|
// 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(ctx context.Context, networkID string, at time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoActiveSubscription = errors.New("network has no stripe customer yet")
|
||||||
|
ErrInvalidCadence = errors.New("invalid billing cadence")
|
||||||
|
)
|
||||||
|
|
||||||
|
type serviceImpl struct {
|
||||||
|
cfg Config
|
||||||
|
repo repository
|
||||||
|
usageRepo usageRepository
|
||||||
|
priceMonthlyCents int64
|
||||||
|
priceAnnualCents int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(ctx context.Context, pool *pgxpool.Pool, cfg Config) (Service, error) {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stripe.Key = cfg.SecretKey
|
||||||
|
|
||||||
|
monthly, err := stripeprice.Get(cfg.PriceMonthlyID, &stripe.PriceParams{
|
||||||
|
Params: stripe.Params{Context: ctx},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch stripe monthly price: %w", err)
|
||||||
|
}
|
||||||
|
annual, err := stripeprice.Get(cfg.PriceAnnualID, &stripe.PriceParams{
|
||||||
|
Params: stripe.Params{Context: ctx},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch stripe annual price: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := &Status{
|
||||||
|
Plan: PlanFree,
|
||||||
|
PlanStatus: "active",
|
||||||
|
PriceMonthlyCents: s.priceMonthlyCents,
|
||||||
|
PriceAnnualCents: s.priceAnnualCents,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sub != nil {
|
||||||
|
cadence := sub.Cadence
|
||||||
|
periodEnd := sub.CurrentPeriodEnd
|
||||||
|
// past_due keeps access: Stripe still considers the subscription live
|
||||||
|
// during the dunning window.
|
||||||
|
switch stripe.SubscriptionStatus(sub.Status) {
|
||||||
|
case stripe.SubscriptionStatusActive,
|
||||||
|
stripe.SubscriptionStatusTrialing,
|
||||||
|
stripe.SubscriptionStatusPastDue:
|
||||||
|
status.Plan = PlanPro
|
||||||
|
}
|
||||||
|
status.PlanStatus = sub.Status
|
||||||
|
status.Cadence = &cadence
|
||||||
|
status.Seats = sub.Quantity
|
||||||
|
status.CurrentPeriodEnd = &periodEnd
|
||||||
|
status.CancelAtPeriodEnd = sub.CancelAtPeriodEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) CreateCheckoutSession(ctx context.Context, p CheckoutParams) (string, error) {
|
||||||
|
if !p.Cadence.IsValid() {
|
||||||
|
return "", ErrInvalidCadence
|
||||||
|
}
|
||||||
|
if p.Seats < 1 {
|
||||||
|
return "", fmt.Errorf("seats must be >= 1")
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, err := s.ensureStripeCustomer(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ensure stripe customer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
priceID := s.cfg.PriceAnnualID
|
||||||
|
switch p.Cadence {
|
||||||
|
case CadenceAnnual:
|
||||||
|
priceID = s.cfg.PriceAnnualID
|
||||||
|
break
|
||||||
|
case CadenceMonthly:
|
||||||
|
priceID = s.cfg.PriceMonthlyID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CheckoutSessionParams{
|
||||||
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||||
|
Customer: stripe.String(customerID),
|
||||||
|
ClientReferenceID: stripe.String(p.NetworkID),
|
||||||
|
SuccessURL: stripe.String(s.cfg.SuccessURL),
|
||||||
|
CancelURL: stripe.String(s.cfg.CancelURL),
|
||||||
|
LineItems: []*stripe.CheckoutSessionLineItemParams{{
|
||||||
|
Price: stripe.String(priceID),
|
||||||
|
Quantity: stripe.Int64(int64(p.Seats)),
|
||||||
|
}},
|
||||||
|
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"network_id": p.NetworkID,
|
||||||
|
"admin_human_id": p.AdminHumanID,
|
||||||
|
"cadence": string(p.Cadence),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
sess, err := checkoutsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stripe checkout: %w", err)
|
||||||
|
}
|
||||||
|
return sess.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) CreatePortalSession(ctx context.Context, networkID string) (string, error) {
|
||||||
|
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return "", ErrNoActiveSubscription
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.BillingPortalSessionParams{
|
||||||
|
Customer: stripe.String(sub.StripeCustomerID),
|
||||||
|
ReturnURL: stripe.String(s.cfg.SuccessURL),
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
sess, err := billingportalsession.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("stripe portal: %w", err)
|
||||||
|
}
|
||||||
|
return sess.URL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) SyncSeats(ctx context.Context, networkID string, seats int) error {
|
||||||
|
sub, err := s.repo.getSubscriptionByNetworkID(ctx, networkID)
|
||||||
|
if errors.Is(err, errNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sub.Quantity == seats {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
liveSub, err := stripesub.Get(sub.ID, &stripe.SubscriptionParams{
|
||||||
|
Params: stripe.Params{Context: ctx},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch stripe subscription: %w", err)
|
||||||
|
}
|
||||||
|
if len(liveSub.Items.Data) == 0 {
|
||||||
|
return fmt.Errorf("stripe subscription %s has no items", sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.SubscriptionParams{
|
||||||
|
ProrationBehavior: stripe.String(string(stripe.SubscriptionSchedulePhaseProrationBehaviorCreateProrations)),
|
||||||
|
Items: []*stripe.SubscriptionItemsParams{{
|
||||||
|
ID: stripe.String(liveSub.Items.Data[0].ID),
|
||||||
|
Quantity: stripe.Int64(int64(seats)),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
if _, err := stripesub.Update(sub.ID, params); err != nil {
|
||||||
|
return fmt.Errorf("update stripe subscription quantity: %w", err)
|
||||||
|
}
|
||||||
|
// customer.subscription.updated webhook arrives within seconds and
|
||||||
|
// reconciles quantity in our DB.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := s.GetStatus(ctx, networkID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to get network billing status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u := &Usage{
|
||||||
|
Plan: sub.Plan,
|
||||||
|
Used: used,
|
||||||
|
ResetAt: nextUTCMidnight(now),
|
||||||
|
Limit: nil,
|
||||||
|
}
|
||||||
|
if sub.Plan == PlanFree {
|
||||||
|
limit := FreemiumDailyLimit
|
||||||
|
u.Limit = &limit
|
||||||
|
}
|
||||||
|
return u, 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 {
|
||||||
|
return existing, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, errNotFound) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := &stripe.CustomerParams{
|
||||||
|
Email: stripe.String(p.AdminEmail),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"network_id": p.NetworkID,
|
||||||
|
"admin_human_id": p.AdminHumanID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
params.Context = ctx
|
||||||
|
|
||||||
|
cust, err := stripecustomer.New(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create stripe customer: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.repo.setStripeCustomerID(ctx, p.NetworkID, cust.ID); err != nil {
|
||||||
|
return "", fmt.Errorf("persist stripe customer id: %w", err)
|
||||||
|
}
|
||||||
|
return cust.ID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// FreemiumDailyLimit is the per-network daily cap on usage,
|
||||||
|
// agnostic of the units that this refer to. This is only relevant for 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package billing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stripe/stripe-go/v85"
|
||||||
|
"github.com/stripe/stripe-go/v85/webhook"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *serviceImpl) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
|
||||||
|
event, err := webhook.ConstructEvent(payload, signature, s.cfg.WebhookSecret)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("verify stripe signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("stripe webhook", "type", event.Type, "id", event.ID)
|
||||||
|
|
||||||
|
switch event.Type {
|
||||||
|
case "checkout.session.completed":
|
||||||
|
// subscription.created fires right after with full detail; we handle
|
||||||
|
// the subscription there.
|
||||||
|
return nil
|
||||||
|
case "customer.subscription.created", "customer.subscription.updated":
|
||||||
|
return s.handleSubscriptionUpsert(ctx, event)
|
||||||
|
case "customer.subscription.deleted":
|
||||||
|
return s.handleSubscriptionDeleted(ctx, event)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) handleSubscriptionUpsert(ctx context.Context, event stripe.Event) error {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
return fmt.Errorf("decode subscription: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := sub.Metadata["network_id"]
|
||||||
|
if networkID == "" {
|
||||||
|
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
local, err := subscriptionFromStripe(&sub, networkID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.upsertSubscription(ctx, local); err != nil {
|
||||||
|
return fmt.Errorf("upsert subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) handleSubscriptionDeleted(ctx context.Context, event stripe.Event) error {
|
||||||
|
var sub stripe.Subscription
|
||||||
|
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
|
||||||
|
return fmt.Errorf("decode subscription: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := sub.Metadata["network_id"]
|
||||||
|
if networkID == "" {
|
||||||
|
return fmt.Errorf("subscription %s missing network_id metadata", sub.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.deleteSubscriptionByID(ctx, sub.ID); err != nil {
|
||||||
|
return fmt.Errorf("delete subscription: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionFromStripe(sub *stripe.Subscription, networkID string) (*Subscription, error) {
|
||||||
|
if len(sub.Items.Data) == 0 {
|
||||||
|
return nil, fmt.Errorf("subscription %s has no items", sub.ID)
|
||||||
|
}
|
||||||
|
item := sub.Items.Data[0]
|
||||||
|
cadence, err := cadenceFromInterval(item.Price)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var customerID string
|
||||||
|
if sub.Customer != nil {
|
||||||
|
customerID = sub.Customer.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Subscription{
|
||||||
|
ID: sub.ID,
|
||||||
|
NetworkID: networkID,
|
||||||
|
StripeCustomerID: customerID,
|
||||||
|
Status: string(sub.Status),
|
||||||
|
PriceID: item.Price.ID,
|
||||||
|
Cadence: cadence,
|
||||||
|
Quantity: int(item.Quantity),
|
||||||
|
CancelAtPeriodEnd: sub.CancelAtPeriodEnd,
|
||||||
|
CurrentPeriodStart: time.Unix(item.CurrentPeriodStart, 0).UTC(),
|
||||||
|
CurrentPeriodEnd: time.Unix(item.CurrentPeriodEnd, 0).UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cadenceFromInterval(price *stripe.Price) (Cadence, error) {
|
||||||
|
if price == nil || price.Recurring == nil {
|
||||||
|
return "", fmt.Errorf("price is not recurring")
|
||||||
|
}
|
||||||
|
switch price.Recurring.Interval {
|
||||||
|
case stripe.PriceRecurringIntervalMonth:
|
||||||
|
return CadenceMonthly, nil
|
||||||
|
case stripe.PriceRecurringIntervalYear:
|
||||||
|
return CadenceAnnual, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported price interval %q", price.Recurring.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
|
"github.com/flowy-live/llink/internal/middleware"
|
||||||
|
"github.com/flowy-live/llink/internal/network"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateCheckoutSessionRequest struct {
|
||||||
|
Cadence string `json:"cadence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckoutSessionResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := h.billingSvc.GetStatus(r.Context(), net.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to get billing status", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreateCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
net, adminHumanId, ok := h.loadNetworkForAdmin(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateCheckoutSessionRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cadence := billing.Cadence(req.Cadence)
|
||||||
|
if !cadence.IsValid() {
|
||||||
|
http.Error(w, "invalid cadence", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
adminHuman, err := h.humanSvc.GetByID(r.Context(), adminHumanId)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load admin human", "error", err, "human_id", adminHumanId)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seats, err := h.networkSvc.CountSeats(r.Context(), net.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to count seats", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := h.billingSvc.CreateCheckoutSession(r.Context(), billing.CheckoutParams{
|
||||||
|
NetworkID: net.ID,
|
||||||
|
AdminHumanID: adminHumanId,
|
||||||
|
AdminEmail: adminHuman.Email,
|
||||||
|
Cadence: cadence,
|
||||||
|
Seats: seats,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create checkout session", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, CheckoutSessionResponse{URL: url})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePortalSession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
net, _, ok := h.loadNetworkForAdmin(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
url, err := h.billingSvc.CreatePortalSession(r.Context(), net.ID)
|
||||||
|
if errors.Is(err, billing.ErrNoActiveSubscription) {
|
||||||
|
http.Error(w, "no active subscription", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create portal session", "error", err, "network_id", net.ID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, PortalSessionResponse{URL: url})
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxStripeWebhookBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
func (h *Handler) HandleStripeWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
payload, err := io.ReadAll(io.LimitReader(r.Body, maxStripeWebhookBytes))
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("stripe webhook: failed to read body", "error", err)
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
signature := r.Header.Get("Stripe-Signature")
|
||||||
|
|
||||||
|
if err := h.billingSvc.HandleWebhook(r.Context(), payload, signature); err != nil {
|
||||||
|
slog.Error("stripe webhook failed", "error", err)
|
||||||
|
formattedErr := fmt.Errorf("webhook processing failed: %w", err)
|
||||||
|
http.Error(w, formattedErr.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadNetworkForAdmin resolves the {id} path param and verifies the caller
|
||||||
|
// is the network's admin. On failure it writes the HTTP error and returns ok=false.
|
||||||
|
func (h *Handler) loadNetworkForAdmin(w http.ResponseWriter, r *http.Request) (*network.Network, string, bool) {
|
||||||
|
humanId, ok := middleware.HumanIdFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
networkID := r.PathValue("id")
|
||||||
|
if networkID == "" {
|
||||||
|
http.Error(w, "network id is required", http.StatusBadRequest)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
net, err := h.networkSvc.GetByID(r.Context(), networkID)
|
||||||
|
if errors.Is(err, network.ErrNotFound) {
|
||||||
|
http.Error(w, "network not found", http.StatusNotFound)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to load network", "error", err, "network_id", networkID)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if net.AdminHumanId != humanId {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return net, humanId, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(body); err != nil {
|
||||||
|
slog.Error("failed to write json", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"cloud.google.com/go/firestore"
|
"cloud.google.com/go/firestore"
|
||||||
"github.com/flowy-live/llink/internal/auth"
|
"github.com/flowy-live/llink/internal/auth"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/depot"
|
"github.com/flowy-live/llink/internal/depot"
|
||||||
"github.com/flowy-live/llink/internal/human"
|
"github.com/flowy-live/llink/internal/human"
|
||||||
"github.com/flowy-live/llink/internal/livekit"
|
"github.com/flowy-live/llink/internal/livekit"
|
||||||
@@ -30,11 +31,22 @@ type Handler struct {
|
|||||||
particleSvc particle.Service
|
particleSvc particle.Service
|
||||||
depotSvc depot.Service
|
depotSvc depot.Service
|
||||||
waitlistSvc waitlist.Service
|
waitlistSvc waitlist.Service
|
||||||
|
billingSvc billing.Service
|
||||||
livekitClient livekit.Client
|
livekitClient livekit.Client
|
||||||
firestoreClient *firestore.Client
|
firestoreClient *firestore.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc network.Service, particleSvc particle.Service, depotSvc depot.Service, waitlistSvc waitlist.Service, livekitClient livekit.Client, firestoreClient *firestore.Client) *Handler {
|
func NewHandler(
|
||||||
|
authSvc auth.AuthService,
|
||||||
|
humanSvc human.Service,
|
||||||
|
networkSvc network.Service,
|
||||||
|
particleSvc particle.Service,
|
||||||
|
depotSvc depot.Service,
|
||||||
|
waitlistSvc waitlist.Service,
|
||||||
|
billingSvc billing.Service,
|
||||||
|
livekitClient livekit.Client,
|
||||||
|
firestoreClient *firestore.Client,
|
||||||
|
) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
authSvc: authSvc,
|
authSvc: authSvc,
|
||||||
humanSvc: humanSvc,
|
humanSvc: humanSvc,
|
||||||
@@ -42,6 +54,7 @@ func NewHandler(authSvc auth.AuthService, humanSvc human.Service, networkSvc net
|
|||||||
particleSvc: particleSvc,
|
particleSvc: particleSvc,
|
||||||
depotSvc: depotSvc,
|
depotSvc: depotSvc,
|
||||||
waitlistSvc: waitlistSvc,
|
waitlistSvc: waitlistSvc,
|
||||||
|
billingSvc: billingSvc,
|
||||||
livekitClient: livekitClient,
|
livekitClient: livekitClient,
|
||||||
firestoreClient: firestoreClient,
|
firestoreClient: firestoreClient,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package network
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type Network struct {
|
type Network struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
AdminHumanId string
|
AdminHumanId string
|
||||||
MemberHumanIds []string
|
MemberHumanIds []string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type Invitation struct {
|
type Invitation struct {
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"go.jetify.com/typeid"
|
"go.jetify.com/typeid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx.
|
||||||
|
// Used by repository helpers that the service layer may run either standalone
|
||||||
|
// (against the pool) or inside a transaction.
|
||||||
|
type dbtx interface {
|
||||||
|
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
|
||||||
|
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||||
|
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
var errNotFound = errors.New("not found")
|
var errNotFound = errors.New("not found")
|
||||||
|
|
||||||
type networkIDPrefix struct{}
|
type networkIDPrefix struct{}
|
||||||
@@ -30,9 +40,10 @@ type repository interface {
|
|||||||
getByID(ctx context.Context, id string) (*Network, error)
|
getByID(ctx context.Context, id string) (*Network, error)
|
||||||
updateName(ctx context.Context, id, name string) error
|
updateName(ctx context.Context, id, name string) error
|
||||||
delete(ctx context.Context, id string) error
|
delete(ctx context.Context, id string) error
|
||||||
addMember(ctx context.Context, networkID, humanId string) error
|
addMember(ctx context.Context, db dbtx, networkID, humanId string) error
|
||||||
removeMember(ctx context.Context, networkID, humanId string) error
|
removeMember(ctx context.Context, db dbtx, networkID, humanId string) error
|
||||||
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
|
getMemberHumanIds(ctx context.Context, networkID string) ([]string, error)
|
||||||
|
countSeats(ctx context.Context, db dbtx, networkID string) (int, error)
|
||||||
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
isMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
listAll(ctx context.Context) ([]*Network, error)
|
listAll(ctx context.Context) ([]*Network, error)
|
||||||
@@ -41,7 +52,15 @@ type repository interface {
|
|||||||
createInvitation(ctx context.Context, networkID, email string) error
|
createInvitation(ctx context.Context, networkID, email string) error
|
||||||
getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error)
|
getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error)
|
||||||
getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
|
getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error)
|
||||||
deleteInvitation(ctx context.Context, networkID, email string) error
|
deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkColumns lists every column selected when hydrating a Network.
|
||||||
|
// Centralized to keep SELECTs and Scan() calls in sync.
|
||||||
|
const networkColumns = `id, name, admin_human_id, created_at`
|
||||||
|
|
||||||
|
func scanNetwork(row pgx.Row, n *Network) error {
|
||||||
|
return row.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
type repositoryImpl struct {
|
type repositoryImpl struct {
|
||||||
@@ -59,12 +78,12 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var n Network
|
var n Network
|
||||||
err = r.pool.QueryRow(ctx,
|
row := r.pool.QueryRow(ctx,
|
||||||
`INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
|
`INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3)
|
||||||
RETURNING id, name, admin_human_id, created_at`,
|
RETURNING `+networkColumns,
|
||||||
id.String(), name, adminHumanId,
|
id.String(), name, adminHumanId,
|
||||||
).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
|
)
|
||||||
if err != nil {
|
if err := scanNetwork(row, &n); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,21 +93,22 @@ func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string)
|
|||||||
|
|
||||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
|
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
|
||||||
var n Network
|
var n Network
|
||||||
err := r.pool.QueryRow(ctx,
|
row := r.pool.QueryRow(ctx,
|
||||||
`SELECT id, name, admin_human_id, created_at FROM networks WHERE id = $1`,
|
`SELECT `+networkColumns+` FROM networks WHERE id = $1`,
|
||||||
id,
|
id,
|
||||||
).Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt)
|
)
|
||||||
if err != nil {
|
if err := scanNetwork(row, &n); err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, errNotFound
|
return nil, errNotFound
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
n.MemberHumanIds, err = r.getMemberHumanIds(ctx, id)
|
memberIds, err := r.getMemberHumanIds(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
n.MemberHumanIds = memberIds
|
||||||
|
|
||||||
return &n, nil
|
return &n, nil
|
||||||
}
|
}
|
||||||
@@ -118,8 +138,8 @@ func (r *repositoryImpl) delete(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) addMember(ctx context.Context, networkID, humanId string) error {
|
func (r *repositoryImpl) addMember(ctx context.Context, db dbtx, networkID, humanId string) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := db.Exec(ctx,
|
||||||
`INSERT INTO network_members (network_id, human_id) VALUES ($1, $2)
|
`INSERT INTO network_members (network_id, human_id) VALUES ($1, $2)
|
||||||
ON CONFLICT (network_id, human_id) DO NOTHING`,
|
ON CONFLICT (network_id, human_id) DO NOTHING`,
|
||||||
networkID, humanId,
|
networkID, humanId,
|
||||||
@@ -127,14 +147,23 @@ func (r *repositoryImpl) addMember(ctx context.Context, networkID, humanId strin
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) removeMember(ctx context.Context, networkID, humanId string) error {
|
func (r *repositoryImpl) removeMember(ctx context.Context, db dbtx, networkID, humanId string) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := db.Exec(ctx,
|
||||||
`DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`,
|
`DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`,
|
||||||
networkID, humanId,
|
networkID, humanId,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *repositoryImpl) countSeats(ctx context.Context, db dbtx, networkID string) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := db.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(*) FROM network_members WHERE network_id = $1`,
|
||||||
|
networkID,
|
||||||
|
).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) {
|
func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT human_id FROM network_members WHERE network_id = $1`,
|
`SELECT human_id FROM network_members WHERE network_id = $1`,
|
||||||
@@ -158,10 +187,10 @@ func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string
|
|||||||
|
|
||||||
func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT n.id, n.name, n.admin_human_id, n.created_at
|
`SELECT `+networkColumns+`
|
||||||
FROM networks n
|
FROM networks
|
||||||
WHERE n.admin_human_id = $1
|
WHERE admin_human_id = $1
|
||||||
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.human_id = $1)`,
|
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = id AND nm.human_id = $1)`,
|
||||||
humanId,
|
humanId,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,7 +201,7 @@ func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string
|
|||||||
var networks []*Network
|
var networks []*Network
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n Network
|
var n Network
|
||||||
if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt); err != nil {
|
if err := scanNetwork(rows, &n); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
networks = append(networks, &n)
|
networks = append(networks, &n)
|
||||||
@@ -205,7 +234,7 @@ func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string
|
|||||||
|
|
||||||
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT id, name, admin_human_id, created_at FROM networks`,
|
`SELECT `+networkColumns+` FROM networks`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -215,7 +244,7 @@ func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) {
|
|||||||
var networks []*Network
|
var networks []*Network
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n Network
|
var n Network
|
||||||
if err := rows.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt); err != nil {
|
if err := scanNetwork(rows, &n); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
networks = append(networks, &n)
|
networks = append(networks, &n)
|
||||||
@@ -293,8 +322,8 @@ func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID
|
|||||||
return invitations, rows.Err()
|
return invitations, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *repositoryImpl) deleteInvitation(ctx context.Context, networkID, email string) error {
|
func (r *repositoryImpl) deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error {
|
||||||
_, err := r.pool.Exec(ctx,
|
_, err := db.Exec(ctx,
|
||||||
`DELETE FROM network_invitations WHERE network_id = $1 AND email = $2`,
|
`DELETE FROM network_invitations WHERE network_id = $1 AND email = $2`,
|
||||||
networkID, email,
|
networkID, email,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/utils"
|
"github.com/flowy-live/llink/internal/utils"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotFound = errors.New("network not found")
|
var ErrNotFound = errors.New("network not found")
|
||||||
@@ -25,8 +28,11 @@ type Service interface {
|
|||||||
GetByID(ctx context.Context, id string) (*Network, error)
|
GetByID(ctx context.Context, id string) (*Network, error)
|
||||||
// SetName returns ErrNotFound or ErrInvalidName.
|
// SetName returns ErrNotFound or ErrInvalidName.
|
||||||
SetName(ctx context.Context, id, name string) error
|
SetName(ctx context.Context, id, name string) error
|
||||||
|
// AddMembers inserts members and syncs the new seat count to billing
|
||||||
|
// atomically; a Stripe failure rolls the insert back.
|
||||||
AddMembers(ctx context.Context, networkID string, humanIds []string) error
|
AddMembers(ctx context.Context, networkID string, humanIds []string) error
|
||||||
RemoveMember(ctx context.Context, networkID, humanId string) error
|
RemoveMember(ctx context.Context, networkID, humanId string) error
|
||||||
|
CountSeats(ctx context.Context, networkID string) (int, error)
|
||||||
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
ListForHuman(ctx context.Context, humanId string) ([]*Network, error)
|
||||||
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
IsMember(ctx context.Context, networkID, humanId string) (bool, error)
|
||||||
// ListAll returns all networks with their members
|
// ListAll returns all networks with their members
|
||||||
@@ -41,12 +47,19 @@ type Service interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type serviceImpl struct {
|
type serviceImpl struct {
|
||||||
repo repository
|
pool *pgxpool.Pool
|
||||||
aeroSvc pbaero.PrimaryClient
|
repo repository
|
||||||
|
aeroSvc pbaero.PrimaryClient
|
||||||
|
billingSvc billing.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient) Service {
|
func NewService(pool *pgxpool.Pool, aeroSvc pbaero.PrimaryClient, billingSvc billing.Service) Service {
|
||||||
return &serviceImpl{repo: newRepository(pool), aeroSvc: aeroSvc}
|
return &serviceImpl{
|
||||||
|
pool: pool,
|
||||||
|
repo: newRepository(pool),
|
||||||
|
aeroSvc: aeroSvc,
|
||||||
|
billingSvc: billingSvc,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
|
func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*Network, error) {
|
||||||
@@ -60,8 +73,7 @@ func (s *serviceImpl) Create(ctx context.Context, name, adminHumanId string) (*N
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.AddMembers(ctx, network.ID, []string{adminHumanId})
|
if err := s.AddMembers(ctx, network.ID, []string{adminHumanId}); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,22 +102,58 @@ func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
|
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, humanIds []string) error {
|
||||||
for _, humanId := range humanIds {
|
if slices.Contains(humanIds, "") {
|
||||||
if humanId == "" {
|
return fmt.Errorf("invalid humanId")
|
||||||
return fmt.Errorf("invalid humanId")
|
|
||||||
}
|
|
||||||
if err := s.repo.addMember(ctx, networkID, humanId); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||||
|
for _, humanId := range humanIds {
|
||||||
|
if err := s.repo.addMember(ctx, tx, networkID, humanId); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
|
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, humanId string) error {
|
||||||
if humanId == "" {
|
if humanId == "" {
|
||||||
return fmt.Errorf("invalid humanId")
|
return fmt.Errorf("invalid humanId")
|
||||||
}
|
}
|
||||||
return s.repo.removeMember(ctx, networkID, humanId)
|
return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||||
|
return s.repo.removeMember(ctx, tx, networkID, humanId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// mutateMembers runs fn in a tx, recounts seats, calls billing.SyncSeats,
|
||||||
|
// and commits. Any error rolls the membership change back.
|
||||||
|
func (s *serviceImpl) mutateMembers(ctx context.Context, networkID string, fn func(pgx.Tx) error) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := fn(tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
seats, err := s.repo.countSeats(ctx, tx, networkID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("count seats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.billingSvc.SyncSeats(ctx, networkID, seats); err != nil {
|
||||||
|
return fmt.Errorf("sync billing seats: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit tx: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *serviceImpl) CountSeats(ctx context.Context, networkID string) (int, error) {
|
||||||
|
return s.repo.countSeats(ctx, s.pool, networkID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
func (s *serviceImpl) ListForHuman(ctx context.Context, humanId string) ([]*Network, error) {
|
||||||
@@ -126,8 +174,6 @@ func (s *serviceImpl) ListAll(ctx context.Context) ([]*Network, error) {
|
|||||||
return s.repo.listAll(ctx)
|
return s.repo.listAll(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invitation methods
|
|
||||||
|
|
||||||
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
|
func (s *serviceImpl) InviteByEmail(ctx context.Context, networkID string, emails []string) error {
|
||||||
network, err := s.repo.getByID(ctx, networkID)
|
network, err := s.repo.getByID(ctx, networkID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,10 +226,13 @@ func (s *serviceImpl) AcceptInvitation(ctx context.Context, networkID, email, hu
|
|||||||
return fmt.Errorf("invalid humanId")
|
return fmt.Errorf("invalid humanId")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.deleteInvitation(ctx, networkID, normalized); err != nil {
|
return s.mutateMembers(ctx, networkID, func(tx pgx.Tx) error {
|
||||||
return err
|
err := s.repo.deleteInvitation(ctx, tx, networkID, normalized)
|
||||||
}
|
if err != nil {
|
||||||
return s.repo.addMember(ctx, networkID, humanId)
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.addMember(ctx, tx, networkID, humanId)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
|
func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email string) error {
|
||||||
@@ -191,7 +240,7 @@ func (s *serviceImpl) RevokeInvitation(ctx context.Context, networkID, email str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid email: %w", err)
|
return fmt.Errorf("invalid email: %w", err)
|
||||||
}
|
}
|
||||||
return s.repo.deleteInvitation(ctx, networkID, normalized)
|
return s.repo.deleteInvitation(ctx, s.pool, networkID, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildInvitationHTML(networkName string) string {
|
func buildInvitationHTML(networkName string) string {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
pbaero "github.com/flowy-live/llink/genproto/aero"
|
pbaero "github.com/flowy-live/llink/genproto/aero"
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"github.com/flowy-live/llink/internal/network/mocks"
|
"github.com/flowy-live/llink/internal/network/mocks"
|
||||||
"github.com/flowy-live/llink/internal/testhelper"
|
"github.com/flowy-live/llink/internal/testhelper"
|
||||||
@@ -31,7 +32,7 @@ func newTestService(t *testing.T) network.Service {
|
|||||||
ShootEmail(gomock.Any(), gomock.Any()).
|
ShootEmail(gomock.Any(), gomock.Any()).
|
||||||
Return(&pbaero.ShootEmailResponse{}, nil).
|
Return(&pbaero.ShootEmailResponse{}, nil).
|
||||||
AnyTimes()
|
AnyTimes()
|
||||||
return network.NewService(dbPool, mockAero)
|
return network.NewService(dbPool, mockAero, billing.Noop())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkService(t *testing.T) {
|
func TestNetworkService(t *testing.T) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/flowy-live/llink/internal/billing"
|
||||||
"github.com/flowy-live/llink/internal/network"
|
"github.com/flowy-live/llink/internal/network"
|
||||||
"github.com/flowy-live/llink/internal/particle"
|
"github.com/flowy-live/llink/internal/particle"
|
||||||
"github.com/flowy-live/llink/internal/testhelper"
|
"github.com/flowy-live/llink/internal/testhelper"
|
||||||
@@ -33,7 +34,7 @@ func getStreamStatus(data json.RawMessage) string {
|
|||||||
|
|
||||||
func TestParticleService_CreateAndGet(t *testing.T) {
|
func TestParticleService_CreateAndGet(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network first
|
// Create a network first
|
||||||
@@ -72,7 +73,7 @@ func TestParticleService_CreateAndGet(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_NestedParticles(t *testing.T) {
|
func TestParticleService_NestedParticles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -118,7 +119,7 @@ func TestParticleService_NestedParticles(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_CustomVisibility(t *testing.T) {
|
func TestParticleService_CustomVisibility(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network with a member
|
// Create a network with a member
|
||||||
@@ -160,7 +161,7 @@ func TestParticleService_CustomVisibility(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -198,7 +199,7 @@ func TestParticleService_UpdateAndDelete(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_ListRootParticles(t *testing.T) {
|
func TestParticleService_ListRootParticles(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -224,7 +225,7 @@ func TestParticleService_ListRootParticles(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_OpenCloseStream(t *testing.T) {
|
func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -272,7 +273,7 @@ func TestParticleService_OpenCloseStream(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_NotAStream(t *testing.T) {
|
func TestParticleService_NotAStream(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network
|
// Create a network
|
||||||
@@ -301,7 +302,7 @@ func TestParticleService_NotAStream(t *testing.T) {
|
|||||||
|
|
||||||
func TestParticleService_AccessInheritance(t *testing.T) {
|
func TestParticleService_AccessInheritance(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
networkSvc := network.NewService(dbPool, nil)
|
networkSvc := network.NewService(dbPool, nil, billing.Noop())
|
||||||
svc := particle.NewService(dbPool, networkSvc)
|
svc := particle.NewService(dbPool, networkSvc)
|
||||||
|
|
||||||
// Create a network with members
|
// Create a network with members
|
||||||
|
|||||||
@@ -61,6 +61,24 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: shared-secrets
|
name: shared-secrets
|
||||||
key: LIVEKIT_URL
|
key: LIVEKIT_URL
|
||||||
|
- name: "STRIPE_SECRET_KEY"
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: shared-secrets
|
||||||
|
key: STRIPE_SECRET_KEY
|
||||||
|
- name: "STRIPE_WEBHOOK_SECRET"
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: shared-secrets
|
||||||
|
key: STRIPE_WEBHOOK_SECRET
|
||||||
|
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
||||||
|
value: "price_1TM9abF9Z3lE6HEQJQmVsWES"
|
||||||
|
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
||||||
|
value: "price_1TM9abF9Z3lE6HEQsToXflbq"
|
||||||
|
- name: "BILLING_SUCCESS_URL"
|
||||||
|
value: "llink://billing/success"
|
||||||
|
- name: "BILLING_CANCEL_URL"
|
||||||
|
value: "llink://billing/cancel"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,24 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: shared-secrets
|
name: shared-secrets
|
||||||
key: LIVEKIT_URL
|
key: LIVEKIT_URL
|
||||||
|
- name: "STRIPE_SECRET_KEY"
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: shared-secrets
|
||||||
|
key: STRIPE_SECRET_KEY
|
||||||
|
- name: "STRIPE_WEBHOOK_SECRET"
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: shared-secrets
|
||||||
|
key: STRIPE_WEBHOOK_SECRET
|
||||||
|
- name: "STRIPE_PRICE_PRO_MONTHLY"
|
||||||
|
value: "price_1TMBElJu6RWBXAm2pPthUoh6"
|
||||||
|
- name: "STRIPE_PRICE_PRO_ANNUAL"
|
||||||
|
value: "price_1TMBElJu6RWBXAm2TOpzdXk5"
|
||||||
|
- name: "BILLING_SUCCESS_URL"
|
||||||
|
value: "llink://billing/success"
|
||||||
|
- name: "BILLING_CANCEL_URL"
|
||||||
|
value: "llink://billing/cancel"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS network_subscriptions;
|
||||||
|
DROP TABLE IF EXISTS network_stripe_customers;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE network_stripe_customers (
|
||||||
|
network_id TEXT PRIMARY KEY,
|
||||||
|
stripe_customer_id TEXT NOT NULL UNIQUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE network_subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
network_id TEXT NOT NULL REFERENCES network_stripe_customers(network_id) ON DELETE CASCADE,
|
||||||
|
stripe_customer_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
price_id TEXT NOT NULL,
|
||||||
|
cadence TEXT NOT NULL,
|
||||||
|
quantity INT NOT NULL,
|
||||||
|
cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
current_period_start TIMESTAMPTZ NOT NULL,
|
||||||
|
current_period_end TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT network_subscriptions_cadence_check CHECK (cadence IN ('monthly', 'annual'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_network_subscriptions_network_id
|
||||||
|
ON network_subscriptions (network_id);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -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;
|
||||||
@@ -2,18 +2,23 @@ import { appConfig } from "@/config/env";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import {
|
import {
|
||||||
|
BillingStatusSchema,
|
||||||
|
CheckoutSessionResponseSchema,
|
||||||
DepotObjectSchema,
|
DepotObjectSchema,
|
||||||
GetLivekitTokenResponseSchema,
|
GetLivekitTokenResponseSchema,
|
||||||
HumanSchema,
|
HumanSchema,
|
||||||
ListInvitationsResponseSchema,
|
ListInvitationsResponseSchema,
|
||||||
ListNetworksResponseSchema,
|
ListNetworksResponseSchema,
|
||||||
NetworkSchema,
|
NetworkSchema,
|
||||||
|
NetworkUsageSchema,
|
||||||
|
PortalSessionResponseSchema,
|
||||||
PrepareUploadResponseSchema,
|
PrepareUploadResponseSchema,
|
||||||
SignInResponseSchema,
|
SignInResponseSchema,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type {
|
import type {
|
||||||
AcceptInvitationRequest,
|
AcceptInvitationRequest,
|
||||||
AddMembersRequest,
|
AddMembersRequest,
|
||||||
|
BillingCadence,
|
||||||
CreateNetworkRequest,
|
CreateNetworkRequest,
|
||||||
PrepareUploadRequest,
|
PrepareUploadRequest,
|
||||||
RequestCodeRequest,
|
RequestCodeRequest,
|
||||||
@@ -216,6 +221,41 @@ class ApiClient {
|
|||||||
async getLivekitToken(networkId: string, streamId: string) {
|
async getLivekitToken(networkId: string, streamId: string) {
|
||||||
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
|
return this.request(GetLivekitTokenResponseSchema, "POST", "/livekit/token", { network_id: networkId, stream_id: streamId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Billing (network admin only) ---
|
||||||
|
|
||||||
|
async getNetworkBilling(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
BillingStatusSchema,
|
||||||
|
"GET",
|
||||||
|
`/networks/${networkId}/billing`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCheckoutSession(networkId: string, cadence: BillingCadence) {
|
||||||
|
return this.request(
|
||||||
|
CheckoutSessionResponseSchema,
|
||||||
|
"POST",
|
||||||
|
`/networks/${networkId}/billing/checkout-session`,
|
||||||
|
{ cadence },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createPortalSession(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
PortalSessionResponseSchema,
|
||||||
|
"POST",
|
||||||
|
`/networks/${networkId}/billing/portal-session`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNetworkUsage(networkId: string) {
|
||||||
|
return this.request(
|
||||||
|
NetworkUsageSchema,
|
||||||
|
"GET",
|
||||||
|
`/networks/${networkId}/usage`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient({
|
export const apiClient = new ApiClient({
|
||||||
|
|||||||
@@ -262,3 +262,53 @@ export const SignInResponseSchema = z.object({
|
|||||||
token: z.string(),
|
token: z.string(),
|
||||||
});
|
});
|
||||||
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
|
||||||
|
|
||||||
|
// --- Billing types ---
|
||||||
|
|
||||||
|
export const BillingCadenceSchema = z.enum(["monthly", "annual"]);
|
||||||
|
export type BillingCadence = z.infer<typeof BillingCadenceSchema>;
|
||||||
|
|
||||||
|
export const NetworkPlanSchema = z.enum(["free", "pro"]);
|
||||||
|
export type NetworkPlan = z.infer<typeof NetworkPlanSchema>;
|
||||||
|
|
||||||
|
// Mirrors Stripe subscription.status plus "active" as the default free-tier value.
|
||||||
|
export const BillingPlanStatusSchema = z.enum([
|
||||||
|
"active",
|
||||||
|
"trialing",
|
||||||
|
"past_due",
|
||||||
|
"canceled",
|
||||||
|
"incomplete",
|
||||||
|
"incomplete_expired",
|
||||||
|
"unpaid",
|
||||||
|
]);
|
||||||
|
export type BillingPlanStatus = z.infer<typeof BillingPlanStatusSchema>;
|
||||||
|
|
||||||
|
export const BillingStatusSchema = z.object({
|
||||||
|
plan: NetworkPlanSchema,
|
||||||
|
plan_status: BillingPlanStatusSchema,
|
||||||
|
cadence: BillingCadenceSchema.nullable(),
|
||||||
|
seats: z.number().int(),
|
||||||
|
current_period_end: z.coerce.date().nullable(),
|
||||||
|
cancel_at_period_end: z.boolean(),
|
||||||
|
price_monthly_cents: z.number().int(),
|
||||||
|
price_annual_cents: z.number().int(),
|
||||||
|
});
|
||||||
|
export type BillingStatus = z.infer<typeof BillingStatusSchema>;
|
||||||
|
|
||||||
|
export const CheckoutSessionResponseSchema = z.object({
|
||||||
|
url: z.string().url(),
|
||||||
|
});
|
||||||
|
export type CheckoutSessionResponse = z.infer<typeof CheckoutSessionResponseSchema>;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid w-full gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="flex size-4 items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
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 { useRecorder } from "@/features/compose/use-recorder";
|
||||||
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
import { useScreenRecorder } from "@/features/compose/use-screen-recorder";
|
||||||
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
import { particlePath, parseParticlePath } from "@/lib/particle-path";
|
||||||
@@ -70,12 +71,17 @@ export function ComposeOverlay({
|
|||||||
const userId = useAuthStore((s) => s.user?.id);
|
const userId = useAuthStore((s) => s.user?.id);
|
||||||
const createParticle = useCreateParticle();
|
const createParticle = useCreateParticle();
|
||||||
const createStream = useCreateStreamParticle();
|
const createStream = useCreateStreamParticle();
|
||||||
|
const { data: usage } = useNetworkUsage(networkId);
|
||||||
|
const invalidateUsage = useInvalidateNetworkUsage();
|
||||||
|
const quotaExhausted = isUsageExhausted(usage);
|
||||||
|
|
||||||
// Refs for synchronous reads in keyboard handlers
|
// Refs for synchronous reads in keyboard handlers
|
||||||
const stepRef = useRef(step);
|
const stepRef = useRef(step);
|
||||||
const recordStartRef = useRef(0);
|
const recordStartRef = useRef(0);
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
disabledRef.current = disabled;
|
disabledRef.current = disabled;
|
||||||
|
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||||
|
quotaExhaustedRef.current = quotaExhausted;
|
||||||
const recordingSourceRef = useRef(recordingSource);
|
const recordingSourceRef = useRef(recordingSource);
|
||||||
recordingSourceRef.current = recordingSource;
|
recordingSourceRef.current = recordingSource;
|
||||||
|
|
||||||
@@ -88,7 +94,12 @@ export function ComposeOverlay({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActiveChange?.(step !== "idle");
|
onActiveChange?.(step !== "idle");
|
||||||
onStepChange?.(step);
|
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[]) => {
|
const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => {
|
||||||
for (const a of items) {
|
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
|
// Reply mode: create particle directly under targetPath
|
||||||
const onSubmitReply = useEffectEvent(async () => {
|
const onSubmitReply = useEffectEvent(async () => {
|
||||||
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
if (!targetPath || !userId || stepRef.current === "submitting") return;
|
||||||
setStepSync("submitting");
|
setStepSync("submitting");
|
||||||
await createChildParticle(targetPath);
|
try {
|
||||||
cancel();
|
await createChildParticle(targetPath);
|
||||||
|
cancel();
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleQuotaError(err)) throw err;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// New stream mode: create stream + first child
|
// New stream mode: create stream + first child
|
||||||
@@ -348,21 +372,25 @@ export function ComposeOverlay({
|
|||||||
if (!userId || stepRef.current === "submitting") return;
|
if (!userId || stepRef.current === "submitting") return;
|
||||||
setStepSync("submitting");
|
setStepSync("submitting");
|
||||||
|
|
||||||
const streamId = await createStream.mutateAsync({
|
try {
|
||||||
networkId,
|
const streamId = await createStream.mutateAsync({
|
||||||
properties: {
|
networkId,
|
||||||
name: streamName,
|
properties: {
|
||||||
},
|
name: streamName,
|
||||||
createdByHumanId: userId,
|
},
|
||||||
visibleTo,
|
createdByHumanId: userId,
|
||||||
});
|
visibleTo,
|
||||||
|
});
|
||||||
|
|
||||||
const streamChildrenPath = particlePath(networkId, [streamId]);
|
const streamChildrenPath = particlePath(networkId, [streamId]);
|
||||||
await createChildParticle(streamChildrenPath);
|
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 ---
|
// --- Keyboard handling ---
|
||||||
@@ -397,6 +425,13 @@ export function ComposeOverlay({
|
|||||||
}
|
}
|
||||||
break;
|
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) {
|
if (e.key === "`" && !e.repeat) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
recordStartRef.current = Date.now();
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Muted } from "@/components/ui/typography";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
useCreateCheckoutSession,
|
||||||
|
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 {
|
||||||
|
if (cents % 100 === 0) return `$${cents / 100}`;
|
||||||
|
return `$${(cents / 100).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlanStatusBadge({ status }: { status: BillingStatus["plan_status"] }) {
|
||||||
|
if (status === "past_due")
|
||||||
|
return <Badge variant="destructive">Past due</Badge>;
|
||||||
|
if (status === "canceled") return <Badge variant="secondary">Canceled</Badge>;
|
||||||
|
if (status === "trialing") return <Badge variant="secondary">Trialing</Badge>;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
value: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
|
<Muted className="text-sm">{label}</Muted>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="text-sm">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CadenceOption({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
perSeatCents,
|
||||||
|
billedNote,
|
||||||
|
saveBadge,
|
||||||
|
selected,
|
||||||
|
}: {
|
||||||
|
value: BillingCadence;
|
||||||
|
label: string;
|
||||||
|
perSeatCents: number;
|
||||||
|
billedNote: string;
|
||||||
|
saveBadge?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
htmlFor={`cadence-${value}`}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-accent flex w-full cursor-pointer items-center gap-3 px-4 py-3 font-normal transition-colors",
|
||||||
|
selected && "bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={`cadence-${value}`} value={value} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium">{label}</p>
|
||||||
|
{saveBadge && <Badge>{saveBadge}</Badge>}
|
||||||
|
</div>
|
||||||
|
<Muted className="text-xs">{billedNote}</Muted>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<p className="text-sm font-medium">{formatCents(perSeatCents)}</p>
|
||||||
|
<Muted className="text-xs">per seat / mo</Muted>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
networkId: string;
|
||||||
|
billing: BillingStatus;
|
||||||
|
}) {
|
||||||
|
const createCheckout = useCreateCheckoutSession(networkId);
|
||||||
|
const [cadence, setCadence] = useState<BillingCadence>("annual");
|
||||||
|
|
||||||
|
const handleUpgrade = () => {
|
||||||
|
createCheckout.mutate(cadence, {
|
||||||
|
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to start checkout"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const annualPerSeatMonthlyCents = Math.round(billing.price_annual_cents / 12);
|
||||||
|
const savingsPct = Math.round(
|
||||||
|
(1 - annualPerSeatMonthlyCents / billing.price_monthly_cents) * 100,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RadioGroup
|
||||||
|
value={cadence}
|
||||||
|
onValueChange={(v) => setCadence(v as BillingCadence)}
|
||||||
|
className="gap-0"
|
||||||
|
>
|
||||||
|
<CadenceOption
|
||||||
|
value="annual"
|
||||||
|
label="Annual"
|
||||||
|
perSeatCents={annualPerSeatMonthlyCents}
|
||||||
|
billedNote="Billed annually"
|
||||||
|
saveBadge={savingsPct > 0 ? `Save ${savingsPct}%` : undefined}
|
||||||
|
selected={cadence === "annual"}
|
||||||
|
/>
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
<CadenceOption
|
||||||
|
value="monthly"
|
||||||
|
label="Monthly"
|
||||||
|
perSeatCents={billing.price_monthly_cents}
|
||||||
|
billedNote="Billed monthly · cancel anytime"
|
||||||
|
selected={cadence === "monthly"}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
disabled={createCheckout.isPending}
|
||||||
|
>
|
||||||
|
{createCheckout.isPending ? "Opening Stripe..." : "Upgrade to Pro"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProBilling({
|
||||||
|
networkId,
|
||||||
|
billing,
|
||||||
|
}: {
|
||||||
|
networkId: string;
|
||||||
|
billing: BillingStatus;
|
||||||
|
}) {
|
||||||
|
const createPortal = useCreatePortalSession(networkId);
|
||||||
|
|
||||||
|
const handleManage = () => {
|
||||||
|
createPortal.mutate(undefined, {
|
||||||
|
onSuccess: ({ url }) => window.electronLink.openExternal(url),
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error(err.message || "Failed to open billing portal"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const cadenceLabel = billing.cadence === "annual" ? "Annual" : "Monthly";
|
||||||
|
const perSeatCents =
|
||||||
|
billing.cadence === "annual"
|
||||||
|
? Math.round(billing.price_annual_cents / 12)
|
||||||
|
: billing.price_monthly_cents;
|
||||||
|
const renewal = billing.current_period_end
|
||||||
|
? formatDate(billing.current_period_end)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{billing.cancel_at_period_end && renewal && (
|
||||||
|
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
|
||||||
|
Your subscription is set to downgrade to Free on {renewal}.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{billing.plan_status === "past_due" && (
|
||||||
|
<div className="border-destructive/30 bg-destructive/10 text-destructive mx-4 my-2 rounded-md border px-3 py-2 text-sm">
|
||||||
|
Your last payment failed. Update your payment method to keep Pro
|
||||||
|
active.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InfoRow
|
||||||
|
label="Billing"
|
||||||
|
value={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{`${cadenceLabel} · ${formatCents(perSeatCents)} / seat / mo`}</span>
|
||||||
|
<PlanStatusBadge status={billing.plan_status} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
<InfoRow label="Seats" value={billing.seats} />
|
||||||
|
{renewal && (
|
||||||
|
<>
|
||||||
|
<Separator className="mx-4" />
|
||||||
|
<InfoRow
|
||||||
|
label={billing.cancel_at_period_end ? "Ends" : "Renews"}
|
||||||
|
value={renewal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleManage}
|
||||||
|
disabled={createPortal.isPending}
|
||||||
|
>
|
||||||
|
<ExternalLink className="mr-2 size-3.5" />
|
||||||
|
{createPortal.isPending
|
||||||
|
? "Opening Stripe..."
|
||||||
|
: "Manage subscription"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Muted className="text-sm">Loading billing...</Muted>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<Muted className="text-sm">Failed to load billing.</Muted>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (billing.plan === "pro") {
|
||||||
|
return <ProBilling networkId={networkId} billing={billing} />;
|
||||||
|
}
|
||||||
|
return <FreeBilling networkId={networkId} billing={billing} />;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { particlePath } from "@/lib/particle-path";
|
|||||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||||
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
import { VideoAudioToggle } from "@/components/video-audio-toggle";
|
||||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||||
|
import { ComposeQuotaIndicator } from "./compose/compose-quota-indicator";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||||
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
|
import { useStreamKeyboardNav } from "@/hooks/use-stream-keyboard-nav";
|
||||||
@@ -50,7 +51,7 @@ export default function NetworkRoot() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
{/* Top bar — stays in place */}
|
{/* Top bar — stays in place */}
|
||||||
<div className="flex shrink-0 items-center justify-between p-1 border-b">
|
<div className="flex shrink-0 items-center p-1 border-b">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={statusTab}
|
value={statusTab}
|
||||||
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")}
|
onValueChange={(v) => setStatusTab(v === "closed" ? "closed" : "open")}
|
||||||
@@ -75,6 +76,11 @@ export default function NetworkRoot() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
|
<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-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
|
||||||
<div className="pointer-events-auto">
|
<div className="pointer-events-auto">
|
||||||
<NetworkRootControls />
|
<NetworkRootControls />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { ArrowLeft, Mail, Shield, X } from "lucide-react";
|
import { ArrowLeft, CreditCard, Mail, Shield, Users, X } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -17,15 +17,10 @@ import {
|
|||||||
useRevokeInvitation,
|
useRevokeInvitation,
|
||||||
} from "@/hooks/use-invitations";
|
} from "@/hooks/use-invitations";
|
||||||
import { useAuthStore } from "@/stores/auth-store";
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
import { BillingSection } from "@/features/network-billing";
|
||||||
import type { Human } from "@/api/types";
|
import type { Human } from "@/api/types";
|
||||||
|
|
||||||
function MemberRow({
|
function MemberRow({ human, isAdmin }: { human: Human; isAdmin: boolean }) {
|
||||||
human,
|
|
||||||
isAdmin,
|
|
||||||
}: {
|
|
||||||
human: Human;
|
|
||||||
isAdmin: boolean;
|
|
||||||
}) {
|
|
||||||
const initials = human.email_prefix.slice(0, 2).toUpperCase();
|
const initials = human.email_prefix.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +68,7 @@ function InviteForm({ networkId }: { networkId: string }) {
|
|||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
|
<form onSubmit={handleSubmit} className="flex items-center gap-2 px-4 py-3">
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email address"
|
placeholder="[email protected]"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
@@ -111,7 +106,7 @@ function PendingInvitationRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center gap-3 px-4 py-3">
|
<div className="flex w-full items-center gap-3 px-4 py-3">
|
||||||
<span className="text-muted-foreground flex size-10 items-center justify-center">
|
<span className="text-muted-foreground flex size-8 items-center justify-center">
|
||||||
<Mail className="size-4" />
|
<Mail className="size-4" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -131,33 +126,63 @@ function PendingInvitationRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsGroup({
|
function SectionHeader({
|
||||||
|
icon,
|
||||||
title,
|
title,
|
||||||
children,
|
description,
|
||||||
|
trailing,
|
||||||
}: {
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
description?: string;
|
||||||
|
trailing?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex items-start gap-3 px-4 pb-2 pt-6">
|
||||||
<p className="text-muted-foreground px-4 pb-1 pt-4 text-xs font-medium uppercase tracking-wider">
|
<span className="text-muted-foreground mt-0.5 flex size-4 items-center justify-center">
|
||||||
{title}
|
{icon}
|
||||||
</p>
|
</span>
|
||||||
<div>{children}</div>
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
||||||
|
{trailing}
|
||||||
|
</div>
|
||||||
|
{description && <Muted className="text-xs">{description}</Muted>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Section({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<section className="bg-card/40 mx-4 mb-2 overflow-hidden rounded-lg border">
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function NetworkSettingsPage() {
|
export default function NetworkSettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { networkId } = useParams<{ networkId: string }>();
|
const { networkId } = useParams<{ networkId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
const network = networks?.find((n) => n.id === networkId);
|
const network = networks?.find((n) => n.id === networkId);
|
||||||
const { data: invitations } = useNetworkInvitations(networkId!);
|
const { data: invitations } = useNetworkInvitations(networkId!);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const isAdmin = currentUser?.id === network?.admin_human.id;
|
const isAdmin = currentUser?.id === network?.admin_human.id;
|
||||||
|
|
||||||
|
const billingRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("section") === "billing") {
|
||||||
|
billingRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const networkName = network?.name ?? "Network";
|
const networkName = network?.name ?? "Network";
|
||||||
|
const memberCount = network?.humans.length ?? 0;
|
||||||
|
const pendingCount = invitations?.length ?? 0;
|
||||||
|
const networkInitials = networkName.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
@@ -171,12 +196,38 @@ export default function NetworkSettingsPage() {
|
|||||||
>
|
>
|
||||||
<ArrowLeft className="size-3.5" />
|
<ArrowLeft className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm font-medium">{networkName}</span>
|
<span className="text-sm font-medium">Settings</span>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<SettingsGroup title="Members">
|
<div className="flex items-center gap-3 px-4 pb-4 pt-6">
|
||||||
|
<Avatar size="lg">
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
|
{networkInitials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-base font-semibold">{networkName}</p>
|
||||||
|
<Muted className="text-xs">
|
||||||
|
{memberCount} {memberCount === 1 ? "member" : "members"}
|
||||||
|
{isAdmin ? " · You're an admin" : ""}
|
||||||
|
</Muted>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<SectionHeader
|
||||||
|
icon={<Users className="size-4" />}
|
||||||
|
title="Members"
|
||||||
|
description="People with access to this network."
|
||||||
|
trailing={
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{memberCount}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
{network?.humans.map((human, index) => (
|
{network?.humans.map((human, index) => (
|
||||||
<div key={human.id}>
|
<div key={human.id}>
|
||||||
<MemberRow
|
<MemberRow
|
||||||
@@ -188,39 +239,65 @@ export default function NetworkSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</SettingsGroup>
|
</Section>
|
||||||
|
|
||||||
<Separator className="mt-4" />
|
|
||||||
|
|
||||||
{isAdmin && network && (
|
{isAdmin && network && (
|
||||||
<>
|
<Section>
|
||||||
<SettingsGroup title="Invite">
|
<SectionHeader
|
||||||
<InviteForm networkId={networkId!} />
|
icon={<Mail className="size-4" />}
|
||||||
</SettingsGroup>
|
title="Invitations"
|
||||||
|
description="Invite teammates by email. They'll get a link to join."
|
||||||
<Separator className="mt-4" />
|
trailing={
|
||||||
|
pendingCount > 0 ? (
|
||||||
<SettingsGroup title="Pending Invitations">
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
{invitations && invitations.length > 0 ? (
|
{pendingCount} pending
|
||||||
invitations.map((inv, index) => (
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<InviteForm networkId={networkId!} />
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="px-4 pb-1 pt-3">
|
||||||
|
<Muted className="text-xs font-medium uppercase tracking-wider">
|
||||||
|
Pending
|
||||||
|
</Muted>
|
||||||
|
</div>
|
||||||
|
{invitations!.map((inv, index) => (
|
||||||
<div key={inv.email}>
|
<div key={inv.email}>
|
||||||
<PendingInvitationRow
|
<PendingInvitationRow
|
||||||
email={inv.email}
|
email={inv.email}
|
||||||
networkId={networkId!}
|
networkId={networkId!}
|
||||||
/>
|
/>
|
||||||
{index < invitations.length - 1 && (
|
{index < invitations!.length - 1 && (
|
||||||
<Separator className="mx-4" />
|
<Separator className="mx-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
) : (
|
</>
|
||||||
<p className="text-muted-foreground px-4 py-3 text-sm">
|
)}
|
||||||
No pending invitations
|
</Section>
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</SettingsGroup>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div ref={billingRef}>
|
||||||
|
<Section>
|
||||||
|
<SectionHeader
|
||||||
|
icon={<CreditCard className="size-4" />}
|
||||||
|
title="Billing"
|
||||||
|
description={
|
||||||
|
isAdmin
|
||||||
|
? "Manage your plan, seats, and payment."
|
||||||
|
: "Your network's current plan and usage."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<BillingSection networkId={networkId!} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-6" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,8 +28,38 @@ import { isParticleDeleted, type Particle, type StreamProperties } from "@/api/t
|
|||||||
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
import type { StreamParticle } from "@/hooks/use-stream-particles";
|
||||||
import { useNetwork } from "@/hooks/use-networks";
|
import { useNetwork } from "@/hooks/use-networks";
|
||||||
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
||||||
|
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||||
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
|
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
|
||||||
|
|
||||||
|
function VideoThumbnail({
|
||||||
|
objectId,
|
||||||
|
isUnseen,
|
||||||
|
}: {
|
||||||
|
objectId: string;
|
||||||
|
isUnseen: boolean;
|
||||||
|
}) {
|
||||||
|
const { data: url } = useDownloadUrl(objectId);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-8 shrink-0 overflow-hidden rounded-md bg-muted",
|
||||||
|
isUnseen && "ring-2 ring-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{url && (
|
||||||
|
<video
|
||||||
|
// Seek ~15 frames in so we skip any initial black/fade-in frames
|
||||||
|
src={`${url}#t=0.5`}
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||||
if (isParticleDeleted(particle)) return Trash2;
|
if (isParticleDeleted(particle)) return Trash2;
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
@@ -156,6 +186,14 @@ const StreamRow = memo(function StreamRow({
|
|||||||
|
|
||||||
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
|
||||||
|
|
||||||
|
const videoThumbObjectId =
|
||||||
|
latestChild &&
|
||||||
|
!isParticleDeleted(latestChild) &&
|
||||||
|
latestChild.type === "media" &&
|
||||||
|
latestChild.properties.mime_type.startsWith("video/")
|
||||||
|
? latestChild.properties.object_id
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -173,13 +211,15 @@ const StreamRow = memo(function StreamRow({
|
|||||||
{shortcutKey}
|
{shortcutKey}
|
||||||
</kbd>
|
</kbd>
|
||||||
)}
|
)}
|
||||||
<Avatar
|
{videoThumbObjectId ? (
|
||||||
className={cn(isUnseen && "ring-2 ring-primary")}
|
<VideoThumbnail objectId={videoThumbObjectId} isUnseen={!!isUnseen} />
|
||||||
>
|
) : (
|
||||||
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
<Avatar className={cn(isUnseen && "ring-2 ring-primary")}>
|
||||||
{initials}
|
<AvatarFallback className="bg-primary/10 text-primary font-medium">
|
||||||
</AvatarFallback>
|
{initials}
|
||||||
</Avatar>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export default function AudioVideoSettingsPage() {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
<div className="space-y-5 px-5 py-5">
|
<div className="space-y-5 px-5 py-5">
|
||||||
{!permissionGranted && (
|
{!permissionGranted && (
|
||||||
<div className="bg-muted/40 flex items-start justify-between gap-3 rounded-md border px-3 py-2.5">
|
<div className="bg-muted/40 flex items-start justify-between gap-3 rounded-md border px-3 py-2.5">
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import { apiClient } from "@/api/client";
|
||||||
|
import type { BillingCadence } from "@/api/types";
|
||||||
|
|
||||||
|
export function useNetworkBilling(networkId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["network-billing", networkId],
|
||||||
|
queryFn: () => apiClient.getNetworkBilling(networkId!),
|
||||||
|
enabled: !!networkId,
|
||||||
|
// Refetch on window focus so the UI catches up after the user returns
|
||||||
|
// from Stripe Checkout (webhook may land a second or two later).
|
||||||
|
// FIX: doesn't work with electron
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchInterval: 10000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCheckoutSession(networkId: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (cadence: BillingCadence) =>
|
||||||
|
apiClient.createCheckoutSession(networkId, cadence),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePortalSession(networkId: string) {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: () => apiClient.createPortalSession(networkId),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 { createParticle, createStreamParticle } from "@/lib/firestore-particles";
|
||||||
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
|
import { CONTAINER_TYPES, type NetworkUsage, type ParticleType, type ParticlePropertiesMap } from "@/api/types";
|
||||||
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
|
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> {
|
interface CreateParticleParams<T extends ParticleType = ParticleType> {
|
||||||
// Path to which the new particle will be added as a child
|
// 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() {
|
export function useCreateParticle() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const bumpUsage = useBumpNetworkUsage();
|
||||||
|
const invalidateUsage = useInvalidateNetworkUsage();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (params: CreateParticleParams) => {
|
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);
|
const collectionPath = toFirestoreChildrenPath(params.path);
|
||||||
return await createParticle(
|
const result = await createParticle(
|
||||||
collectionPath,
|
collectionPath,
|
||||||
params.type,
|
params.type,
|
||||||
params.properties,
|
params.properties,
|
||||||
params.createdByHumanId,
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/stores/auth-store";
|
||||||
|
|
||||||
export function useNetworks() {
|
export function useNetworks() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -12,3 +13,10 @@ export function useNetwork(networkId: string) {
|
|||||||
const { data: networks } = useNetworks();
|
const { data: networks } = useNetworks();
|
||||||
return networks?.find((n) => n.id === networkId) || null;
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import type { Particle } from "@/api/types";
|
import type { Particle } from "@/api/types";
|
||||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
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";
|
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||||
|
|
||||||
// --- Playback reducer (ID-based) ---
|
// --- Playback reducer (ID-based) ---
|
||||||
|
|||||||
+11
-1
@@ -25,11 +25,21 @@ if (process.platform === 'darwin' && !app.isPackaged) {
|
|||||||
app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png'));
|
app.dock?.setIcon(path.join(__dirname, '../../assets/icon.png'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In dev, `LLINK_PROFILE=foo yarn start` spins up a second instance with an
|
||||||
|
// isolated userData dir so it can coexist with the default one (separate auth,
|
||||||
|
// cookies, leveldb locks).
|
||||||
|
const devProfile = !app.isPackaged ? process.env.LLINK_PROFILE : undefined;
|
||||||
|
if (devProfile) {
|
||||||
|
app.setPath('userData', `${app.getPath('userData')}-${devProfile}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new
|
// Single-instance lock: on Windows/Linux, clicking a llink:// URL launches a new
|
||||||
// process. The lock makes the losing instance quit and fires `second-instance` on
|
// process. The lock makes the losing instance quit and fires `second-instance` on
|
||||||
// the primary, so we focus the existing window instead of spawning a duplicate.
|
// the primary, so we focus the existing window instead of spawning a duplicate.
|
||||||
// macOS uses `open-url` instead and doesn't need this, but the lock is harmless.
|
// macOS uses `open-url` instead and doesn't need this, but the lock is harmless.
|
||||||
if (!app.requestSingleInstanceLock()) {
|
// Skip the lock when running a named dev profile — those instances are meant to
|
||||||
|
// run alongside the default one.
|
||||||
|
if (!devProfile && !app.requestSingleInstanceLock()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user