From fd6f6493b6dee678a5c0cf55c8ed02c2701910f9 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 23 Apr 2026 21:56:19 +0530 Subject: [PATCH 1/6] refactor: split cmd/server entrypoint from internal/server package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the repo for open-sourcing. The 32-file flat root of package main moves wholesale to internal/server with no logic changes — just package rename and the main() → Run() conversion needed so cmd/server can import it. - cmd/server/main.go: thin entrypoint that calls server.Run() - internal/server/*: all former root .go files (auth, billing, handlers, config, observability, postgres, redis, reaper, ratelimit, dashboard, openapi, email, inbound, razorpay). Tests move alongside their sources and stay package-private. - //go:embed assets (llms.txt, schema.sql, openapi.json) move into internal/server alongside the files that embed them. - Dockerfile: build ./cmd/server and COPY internal/server/schema.sql. go build ./..., go vet ./..., go test ./... all pass. --- Dockerfile | 4 ++-- cmd/server/main.go | 12 ++++++++++++ auth.go => internal/server/auth.go | 2 +- auth_test.go => internal/server/auth_test.go | 2 +- billing.go => internal/server/billing.go | 2 +- .../server/billing_change_plan.go | 2 +- .../server/billing_emails.go | 2 +- .../server/billing_razorpay_sdk_test.go | 2 +- .../server/billing_reconciler.go | 2 +- billing_test.go => internal/server/billing_test.go | 2 +- .../server/billing_webhook_test.go | 2 +- .../server/claim_preview_test.go | 2 +- config.go => internal/server/config.go | 2 +- config_test.go => internal/server/config_test.go | 2 +- dashboard.go => internal/server/dashboard.go | 2 +- email.go => internal/server/email.go | 2 +- email_test.go => internal/server/email_test.go | 2 +- handlers.go => internal/server/handlers.go | 2 +- inbound.go => internal/server/inbound.go | 2 +- .../server/inbound_reconciler.go | 2 +- .../server/inbound_reconciler_test.go | 2 +- inbound_test.go => internal/server/inbound_test.go | 2 +- llms.txt => internal/server/llms.txt | 0 main.go => internal/server/main.go | 9 +++++++-- observability.go => internal/server/observability.go | 2 +- openapi.go => internal/server/openapi.go | 2 +- openapi.json => internal/server/openapi.json | 0 .../server/plan_switch_test.go | 2 +- plan_test.go => internal/server/plan_test.go | 2 +- postgres.go => internal/server/postgres.go | 2 +- postgres_test.go => internal/server/postgres_test.go | 2 +- ratelimit.go => internal/server/ratelimit.go | 2 +- .../server/razorpay_client.go | 2 +- .../server/razorpay_mock_test.go | 2 +- reaper.go => internal/server/reaper.go | 2 +- redis_prov.go => internal/server/redis_prov.go | 2 +- schema.sql => internal/server/schema.sql | 0 37 files changed, 52 insertions(+), 35 deletions(-) create mode 100644 cmd/server/main.go rename auth.go => internal/server/auth.go (99%) rename auth_test.go => internal/server/auth_test.go (99%) rename billing.go => internal/server/billing.go (99%) rename billing_change_plan.go => internal/server/billing_change_plan.go (99%) rename billing_emails.go => internal/server/billing_emails.go (99%) rename billing_razorpay_sdk_test.go => internal/server/billing_razorpay_sdk_test.go (99%) rename billing_reconciler.go => internal/server/billing_reconciler.go (99%) rename billing_test.go => internal/server/billing_test.go (99%) rename billing_webhook_test.go => internal/server/billing_webhook_test.go (99%) rename claim_preview_test.go => internal/server/claim_preview_test.go (99%) rename config.go => internal/server/config.go (99%) rename config_test.go => internal/server/config_test.go (99%) rename dashboard.go => internal/server/dashboard.go (99%) rename email.go => internal/server/email.go (99%) rename email_test.go => internal/server/email_test.go (99%) rename handlers.go => internal/server/handlers.go (99%) rename inbound.go => internal/server/inbound.go (99%) rename inbound_reconciler.go => internal/server/inbound_reconciler.go (99%) rename inbound_reconciler_test.go => internal/server/inbound_reconciler_test.go (99%) rename inbound_test.go => internal/server/inbound_test.go (99%) rename llms.txt => internal/server/llms.txt (100%) rename main.go => internal/server/main.go (97%) rename observability.go => internal/server/observability.go (99%) rename openapi.go => internal/server/openapi.go (98%) rename openapi.json => internal/server/openapi.json (100%) rename plan_switch_test.go => internal/server/plan_switch_test.go (99%) rename plan_test.go => internal/server/plan_test.go (99%) rename postgres.go => internal/server/postgres.go (99%) rename postgres_test.go => internal/server/postgres_test.go (99%) rename ratelimit.go => internal/server/ratelimit.go (99%) rename razorpay_client.go => internal/server/razorpay_client.go (98%) rename razorpay_mock_test.go => internal/server/razorpay_mock_test.go (99%) rename reaper.go => internal/server/reaper.go (99%) rename redis_prov.go => internal/server/redis_prov.go (95%) rename schema.sql => internal/server/schema.sql (100%) diff --git a/Dockerfile b/Dockerfile index a800e00..9946f4b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,12 +3,12 @@ WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/instant-lite . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/instant-lite ./cmd/server FROM alpine:3.20 RUN apk add --no-cache ca-certificates postgresql-client gettext COPY --from=build /bin/instant-lite /usr/local/bin/instant-lite -COPY schema.sql /app/schema.sql +COPY internal/server/schema.sql /app/schema.sql COPY config.prod.yaml.tpl /app/config.prod.yaml.tpl ENV CONFIG_PATH=/app/config.yaml EXPOSE 8080 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..f25afee --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,12 @@ +// Command server is the instant-lite-api entrypoint. +// +// It is intentionally thin: everything lives in internal/server so that +// future cmd/ binaries (e.g. migration tools, one-off jobs) can reuse +// the same building blocks without duplicating setup. +package main + +import "instant.dev/lite/internal/server" + +func main() { + server.Run() +} diff --git a/auth.go b/internal/server/auth.go similarity index 99% rename from auth.go rename to internal/server/auth.go index df610ff..81e647b 100644 --- a/auth.go +++ b/internal/server/auth.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/auth_test.go b/internal/server/auth_test.go similarity index 99% rename from auth_test.go rename to internal/server/auth_test.go index 4e9e685..4ac5f8f 100644 --- a/auth_test.go +++ b/internal/server/auth_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "encoding/json" diff --git a/billing.go b/internal/server/billing.go similarity index 99% rename from billing.go rename to internal/server/billing.go index 0659578..c82c7e4 100644 --- a/billing.go +++ b/internal/server/billing.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/billing_change_plan.go b/internal/server/billing_change_plan.go similarity index 99% rename from billing_change_plan.go rename to internal/server/billing_change_plan.go index 3a59644..525deec 100644 --- a/billing_change_plan.go +++ b/internal/server/billing_change_plan.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/billing_emails.go b/internal/server/billing_emails.go similarity index 99% rename from billing_emails.go rename to internal/server/billing_emails.go index 8269164..459ce47 100644 --- a/billing_emails.go +++ b/internal/server/billing_emails.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/billing_razorpay_sdk_test.go b/internal/server/billing_razorpay_sdk_test.go similarity index 99% rename from billing_razorpay_sdk_test.go rename to internal/server/billing_razorpay_sdk_test.go index 7d3c120..e6cc0ea 100644 --- a/billing_razorpay_sdk_test.go +++ b/internal/server/billing_razorpay_sdk_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/billing_reconciler.go b/internal/server/billing_reconciler.go similarity index 99% rename from billing_reconciler.go rename to internal/server/billing_reconciler.go index 6d4bfc7..1d4dfa9 100644 --- a/billing_reconciler.go +++ b/internal/server/billing_reconciler.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/billing_test.go b/internal/server/billing_test.go similarity index 99% rename from billing_test.go rename to internal/server/billing_test.go index ece8a4c..3447bf0 100644 --- a/billing_test.go +++ b/internal/server/billing_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "crypto/hmac" diff --git a/billing_webhook_test.go b/internal/server/billing_webhook_test.go similarity index 99% rename from billing_webhook_test.go rename to internal/server/billing_webhook_test.go index 80edd18..73dab2f 100644 --- a/billing_webhook_test.go +++ b/internal/server/billing_webhook_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "bytes" diff --git a/claim_preview_test.go b/internal/server/claim_preview_test.go similarity index 99% rename from claim_preview_test.go rename to internal/server/claim_preview_test.go index 57ff5ba..f41ec65 100644 --- a/claim_preview_test.go +++ b/internal/server/claim_preview_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "encoding/json" diff --git a/config.go b/internal/server/config.go similarity index 99% rename from config.go rename to internal/server/config.go index 54ab239..7ca31c5 100644 --- a/config.go +++ b/internal/server/config.go @@ -1,4 +1,4 @@ -package main +package server import ( "fmt" diff --git a/config_test.go b/internal/server/config_test.go similarity index 99% rename from config_test.go rename to internal/server/config_test.go index 0b41edd..024e32c 100644 --- a/config_test.go +++ b/internal/server/config_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "testing" diff --git a/dashboard.go b/internal/server/dashboard.go similarity index 99% rename from dashboard.go rename to internal/server/dashboard.go index c0685b1..b87f277 100644 --- a/dashboard.go +++ b/internal/server/dashboard.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/email.go b/internal/server/email.go similarity index 99% rename from email.go rename to internal/server/email.go index 48e9866..5f27ded 100644 --- a/email.go +++ b/internal/server/email.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/email_test.go b/internal/server/email_test.go similarity index 99% rename from email_test.go rename to internal/server/email_test.go index 38a2460..aaaa419 100644 --- a/email_test.go +++ b/internal/server/email_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "regexp" diff --git a/handlers.go b/internal/server/handlers.go similarity index 99% rename from handlers.go rename to internal/server/handlers.go index 059878b..4f0bc9f 100644 --- a/handlers.go +++ b/internal/server/handlers.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/inbound.go b/internal/server/inbound.go similarity index 99% rename from inbound.go rename to internal/server/inbound.go index 9c37e6b..e54e4bc 100644 --- a/inbound.go +++ b/internal/server/inbound.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/inbound_reconciler.go b/internal/server/inbound_reconciler.go similarity index 99% rename from inbound_reconciler.go rename to internal/server/inbound_reconciler.go index bf80ac6..c828f99 100644 --- a/inbound_reconciler.go +++ b/internal/server/inbound_reconciler.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/inbound_reconciler_test.go b/internal/server/inbound_reconciler_test.go similarity index 99% rename from inbound_reconciler_test.go rename to internal/server/inbound_reconciler_test.go index 5746307..8fc8d22 100644 --- a/inbound_reconciler_test.go +++ b/internal/server/inbound_reconciler_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "encoding/json" diff --git a/inbound_test.go b/internal/server/inbound_test.go similarity index 99% rename from inbound_test.go rename to internal/server/inbound_test.go index dae047f..d3d2262 100644 --- a/inbound_test.go +++ b/internal/server/inbound_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "bytes" diff --git a/llms.txt b/internal/server/llms.txt similarity index 100% rename from llms.txt rename to internal/server/llms.txt diff --git a/main.go b/internal/server/main.go similarity index 97% rename from main.go rename to internal/server/main.go index 3830c5e..2679fbf 100644 --- a/main.go +++ b/internal/server/main.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" @@ -36,7 +36,12 @@ type server struct { email *emailer } -func main() { +// Run boots the instant-lite server: loads config, initializes +// observability, opens the platform DB and Redis client, starts the +// background reapers/reconcilers, registers routes, and serves HTTP +// until SIGINT/SIGTERM. It is the package-level entrypoint invoked by +// cmd/server. +func Run() { // CONFIG_PATH is the only environment variable used — everything else lives in config.yaml. configPath := "config.yaml" if v := os.Getenv("CONFIG_PATH"); v != "" { diff --git a/observability.go b/internal/server/observability.go similarity index 99% rename from observability.go rename to internal/server/observability.go index 80d9375..554bc0e 100644 --- a/observability.go +++ b/internal/server/observability.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/openapi.go b/internal/server/openapi.go similarity index 98% rename from openapi.go rename to internal/server/openapi.go index 63afd3f..dc01706 100644 --- a/openapi.go +++ b/internal/server/openapi.go @@ -1,4 +1,4 @@ -package main +package server import ( _ "embed" diff --git a/openapi.json b/internal/server/openapi.json similarity index 100% rename from openapi.json rename to internal/server/openapi.json diff --git a/plan_switch_test.go b/internal/server/plan_switch_test.go similarity index 99% rename from plan_switch_test.go rename to internal/server/plan_switch_test.go index 95b962c..03d65d1 100644 --- a/plan_switch_test.go +++ b/internal/server/plan_switch_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "bytes" diff --git a/plan_test.go b/internal/server/plan_test.go similarity index 99% rename from plan_test.go rename to internal/server/plan_test.go index f5b3c06..960b2f1 100644 --- a/plan_test.go +++ b/internal/server/plan_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "strings" diff --git a/postgres.go b/internal/server/postgres.go similarity index 99% rename from postgres.go rename to internal/server/postgres.go index 5569050..c9e08d9 100644 --- a/postgres.go +++ b/internal/server/postgres.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/postgres_test.go b/internal/server/postgres_test.go similarity index 99% rename from postgres_test.go rename to internal/server/postgres_test.go index a40a185..fa0dcca 100644 --- a/postgres_test.go +++ b/internal/server/postgres_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "strings" diff --git a/ratelimit.go b/internal/server/ratelimit.go similarity index 99% rename from ratelimit.go rename to internal/server/ratelimit.go index 53c6050..612321c 100644 --- a/ratelimit.go +++ b/internal/server/ratelimit.go @@ -1,4 +1,4 @@ -package main +package server import ( "net/http" diff --git a/razorpay_client.go b/internal/server/razorpay_client.go similarity index 98% rename from razorpay_client.go rename to internal/server/razorpay_client.go index caf1b4f..2148552 100644 --- a/razorpay_client.go +++ b/internal/server/razorpay_client.go @@ -1,4 +1,4 @@ -package main +package server import ( "sync/atomic" diff --git a/razorpay_mock_test.go b/internal/server/razorpay_mock_test.go similarity index 99% rename from razorpay_mock_test.go rename to internal/server/razorpay_mock_test.go index df2ede7..3f89736 100644 --- a/razorpay_mock_test.go +++ b/internal/server/razorpay_mock_test.go @@ -1,4 +1,4 @@ -package main +package server import ( "encoding/json" diff --git a/reaper.go b/internal/server/reaper.go similarity index 99% rename from reaper.go rename to internal/server/reaper.go index 42818bb..e8807af 100644 --- a/reaper.go +++ b/internal/server/reaper.go @@ -1,4 +1,4 @@ -package main +package server import ( "context" diff --git a/redis_prov.go b/internal/server/redis_prov.go similarity index 95% rename from redis_prov.go rename to internal/server/redis_prov.go index e613c2b..454fd1f 100644 --- a/redis_prov.go +++ b/internal/server/redis_prov.go @@ -1,4 +1,4 @@ -package main +package server // Cache-as-a-service is intentionally not offered. The managed Valkey is // internal-only (rate limits + webhook storage). If you reintroduce a Redis diff --git a/schema.sql b/internal/server/schema.sql similarity index 100% rename from schema.sql rename to internal/server/schema.sql From 45a325113126989a56ddb851fe4cbfd4399134f7 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 23 Apr 2026 22:01:22 +0530 Subject: [PATCH 2/6] refactor(billing): split billing.go into focused files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit billing.go was 981 LOC doing orders, webhooks, subscriptions, migration, and shared helpers all at once. Split into: - billing_orders.go (121 LOC): CreateOrder flow + planPricing - billing_webhook.go (340 LOC): Razorpay webhook dispatcher, payment.captured, signature verification, payment-failed emails - billing_subscriptions.go (364 LOC): Create + lifecycle (activated/charged/halted/cancelled/completed) - billing.go (184 LOC): shared helpers (userIDFromNotes, unixToTime, periodFromSubscription), pure helpers (planConfig, normalizeCurrency, pickPlanID, subscriptionStatusBlocksNew), deprecated migrate shim No behaviour change — same package, same symbols, content preserved line-for-line. go build / go vet / go test all pass. --- internal/server/billing.go | 799 +---------------------- internal/server/billing_orders.go | 121 ++++ internal/server/billing_subscriptions.go | 364 +++++++++++ internal/server/billing_webhook.go | 340 ++++++++++ 4 files changed, 826 insertions(+), 798 deletions(-) create mode 100644 internal/server/billing_orders.go create mode 100644 internal/server/billing_subscriptions.go create mode 100644 internal/server/billing_webhook.go diff --git a/internal/server/billing.go b/internal/server/billing.go index c82c7e4..f9aa909 100644 --- a/internal/server/billing.go +++ b/internal/server/billing.go @@ -2,11 +2,6 @@ package server import ( "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" "log/slog" "net/http" "strings" @@ -15,799 +10,7 @@ import ( "github.com/google/uuid" ) -type CreateOrderRequest struct { - PlanID string `json:"plan_id"` // e.g., "developer" - Currency string `json:"currency"` // "USD" | "EUR" | "GBP" | "INR" - Token string `json:"token,omitempty"` // optional anon resource token to upgrade atomically on payment -} - -type CreateOrderResponse struct { - OrderID string `json:"order_id"` - Amount int `json:"amount"` - Currency string `json:"currency"` - KeyID string `json:"key_id"` - Name string `json:"name"` - Email string `json:"email"` - Contact string `json:"contact"` -} - -// planPricing holds minor-unit amounts (cents / paise) per currency. -// Monthly Developer: $12. Annual Developer: $120 (two months free). -// INR prices mirror the USD ratio — ₹999/mo and ₹9,990/yr. -var planPricing = map[string]map[string]int{ - "developer": { - "USD": 1200, - "EUR": 1200, - "GBP": 1200, - "INR": 99900, - }, - "developer-annual": { - "USD": 12000, - "EUR": 12000, - "GBP": 12000, - "INR": 999000, - }, -} - -func (s *server) handleCreateOrder(w http.ResponseWriter, r *http.Request) { - // Note: no direct platform-PG calls here; authUser handles its own 5s - // timeout internally. The only external call is client.Order.Create - // below, which the Razorpay Go SDK runs without context support — see - // comment at that call site. - user := s.authUser(r) - if user == nil { - writeError(w, http.StatusUnauthorized, "unauthorized", "Sign in required.") - return - } - - var req CreateOrderRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid_body", "Request body must be JSON.") - return - } - - if req.Currency == "" { - req.Currency = "USD" - } - - currencies, ok := planPricing[req.PlanID] - if !ok { - writeError(w, http.StatusBadRequest, "invalid_plan", "Unknown plan_id.") - return - } - amount, ok := currencies[req.Currency] - if !ok { - writeError(w, http.StatusBadRequest, "invalid_currency", "Supported currencies: USD, EUR, GBP, INR.") - return - } - - client := newRazorpayClient(s.cfg.Razorpay) - - notes := map[string]interface{}{ - "user_id": user.ID.String(), - "plan_id": req.PlanID, - } - if req.Token != "" { - if _, err := uuid.Parse(req.Token); err == nil { - notes["token"] = req.Token - } - } - - data := map[string]interface{}{ - "amount": amount, - "currency": req.Currency, - "receipt": uuid.New().String(), - "payment_capture": 1, - "notes": notes, - } - - // LIMITATION: the Razorpay Go SDK does not accept a context.Context here, - // so we cannot enforce our 5s request budget on this call. It will stall - // up to Razorpay's own SDK-internal HTTP timeout (currently unbounded in - // razorpay-go). If this becomes a production hang risk, wrap with a - // channel + time.After pattern and abandon the goroutine on timeout. - order, err := client.Order.Create(data, nil) - if err != nil { - slog.ErrorContext(r.Context(), "razorpay order create failed", "error", err, "user_id", user.ID, "plan", req.PlanID, "currency", req.Currency) - writeError(w, http.StatusBadGateway, "payment_gateway_error", "Payment provider is unavailable — please try again in a moment.") - return - } - - response := CreateOrderResponse{ - OrderID: order["id"].(string), - Amount: amount, - Currency: req.Currency, - KeyID: s.cfg.Razorpay.KeyID, - Name: "InstaNode User", - Email: user.Email, - Contact: "", // Optional - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(response) -} - -func (s *server) handleRazorpayWebhook(w http.ResponseWriter, r *http.Request) { - // Bound platform-PG dedup insert + downstream handlePaymentCaptured calls - // to 5s so a stuck platform-PG can't hang this request. We intentionally - // pick the request's 5s budget rather than the full Razorpay retry window - // — Razorpay will retry on our 500, which is safer than a hung handler. - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - body, err := io.ReadAll(r.Body) - if err != nil { - slog.ErrorContext(r.Context(), "razorpay webhook: body read failed", "error", err) - writeError(w, http.StatusBadRequest, "invalid_body", "Could not read request body.") - return - } - - signature := r.Header.Get("X-Razorpay-Signature") - expectedSignature := s.computeSignature(string(body), s.cfg.Razorpay.WebhookSecret) - if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { - slog.WarnContext(r.Context(), "razorpay webhook: signature mismatch") - writeError(w, http.StatusUnauthorized, "invalid_signature", "Signature verification failed.") - return - } - - var event map[string]interface{} - if err := json.Unmarshal(body, &event); err != nil { - slog.WarnContext(r.Context(), "razorpay webhook: invalid JSON", "error", err) - writeError(w, http.StatusBadRequest, "invalid_json", "Body is not valid JSON.") - return - } - - eventType, ok := event["event"].(string) - if !ok { - slog.WarnContext(r.Context(), "razorpay webhook: missing event type") - writeError(w, http.StatusBadRequest, "missing_event", "Payload has no 'event' field.") - return - } - - // Idempotency key: prefer Razorpay's event-level id when present (every - // webhook carries one); fall back to payment/subscription entity ids. - payload, _ := event["payload"].(map[string]interface{}) - paymentMap, _ := payload["payment"].(map[string]interface{}) - paymentEntity, _ := paymentMap["entity"].(map[string]interface{}) - subMap, _ := payload["subscription"].(map[string]interface{}) - subEntity, _ := subMap["entity"].(map[string]interface{}) - - dedupID, _ := event["id"].(string) - if dedupID == "" { - if p, ok := paymentEntity["id"].(string); ok { - dedupID = p - } else if s, ok := subEntity["id"].(string); ok { - dedupID = s + ":" + eventType - } - } - if dedupID == "" { - slog.Warn("razorpay webhook: no dedup id", "event", eventType) - w.WriteHeader(http.StatusOK) - return - } - - // Idempotency: record the dedup id; if it was already seen, no-op. - res, err := s.db.ExecContext(ctx, - "INSERT INTO processed_webhooks (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING", - dedupID, - ) - if err != nil { - slog.ErrorContext(r.Context(), "webhook dedup insert failed", "error", err, "dedup_id", dedupID) - writeError(w, http.StatusInternalServerError, "internal_error", "Could not process webhook — please retry.") - return - } - rows, _ := res.RowsAffected() - if rows == 0 { - slog.Info("razorpay webhook already processed; skipping", "dedup_id", dedupID, "event", eventType) - w.WriteHeader(http.StatusOK) - return - } - - switch eventType { - case "payment.captured": - s.handlePaymentCaptured(ctx, paymentEntity, dedupID) - case "payment.failed": - orderID, _ := paymentEntity["order_id"].(string) - subID, _ := paymentEntity["subscription_id"].(string) - reason, _ := paymentEntity["error_description"].(string) - if reason == "" { - reason, _ = paymentEntity["error_reason"].(string) - } - slog.Warn("razorpay payment failed", "dedup_id", dedupID, "order_id", orderID, "sub_id", subID, "reason", reason) - s.notifyPaymentFailed(ctx, orderID, reason) - // If this failure is the first charge on a subscription, the user's - // subscription row in our DB is stuck at status='created' with a - // sub_id pointing at a Razorpay subscription that'll never activate. - // Clear it so their next Subscribe click starts clean — otherwise - // the already_subscribed guard traps them indefinitely. - if subID != "" { - if _, err := s.db.ExecContext(ctx, - `UPDATE users SET razorpay_subscription_id = NULL, subscription_status = NULL - WHERE razorpay_subscription_id = $1`, subID); err != nil { - slog.WarnContext(ctx, "clear stuck subscription after payment.failed", "error", err, "sub_id", subID) - } - } - case "subscription.activated", "subscription.charged": - s.handleSubscriptionCharged(ctx, subEntity, paymentEntity) - case "subscription.halted": - s.handleSubscriptionHalted(ctx, subEntity) - case "subscription.cancelled": - s.handleSubscriptionCancelled(ctx, subEntity) - case "subscription.completed": - s.handleSubscriptionCompleted(ctx, subEntity) - case "subscription.authenticated", "subscription.pending", "subscription.paused", "subscription.resumed": - slog.Info("razorpay subscription lifecycle event", "event", eventType, "sub_id", subEntity["id"]) - default: - slog.Info("razorpay webhook event ignored", "event", eventType, "dedup_id", dedupID) - } - - w.WriteHeader(http.StatusOK) -} - -// handlePaymentCaptured promotes the paying user's resources to the paid tier. -// Errors are logged but not returned — we've already recorded the payment id as -// processed, so returning 200 is correct; operator alerts pick up the log. -// ctx is the 5s-bounded request context from handleRazorpayWebhook. -// -// User resolution has two paths: -// 1. Legacy one-time Orders flow: order carries notes.user_id (set by our -// /billing/create-order handler). Fetch order, read notes. -// 2. Subscription flow: the payment entity has subscription_id but the -// auto-generated order carries no notes. Look up the subscription_id in -// our users table to resolve the owner. -func (s *server) handlePaymentCaptured(ctx context.Context, entity map[string]interface{}, paymentID string) { - customerID, _ := entity["customer_id"].(string) - subID, _ := entity["subscription_id"].(string) - orderID, _ := entity["order_id"].(string) - - var userID uuid.UUID - period := "monthly" - resolvedVia := "" - - // Subscription path first — the payment entity tells us this is a - // subscription charge, so skip the order-notes lookup (which would fail). - if subID != "" { - err := s.db.QueryRowContext(ctx, - "SELECT id, COALESCE(plan_period,'monthly') FROM users WHERE razorpay_subscription_id = $1", - subID, - ).Scan(&userID, &period) - if err == nil { - resolvedVia = "subscription_id" - } else { - slog.Warn("payment.captured: subscription lookup failed; falling back to order notes", - "error", err, "sub_id", subID, "payment_id", paymentID) - } - } - - // Order-notes fallback (legacy one-time-order checkout path). - if resolvedVia == "" { - if orderID == "" { - slog.Error("payment.captured missing order_id and unresolvable subscription_id", "payment_id", paymentID) - return - } - client := newRazorpayClient(s.cfg.Razorpay) - order, err := client.Order.Fetch(orderID, nil, nil) - if err != nil { - slog.Error("razorpay order fetch failed", "error", err, "order_id", orderID, "payment_id", paymentID) - return - } - notes, ok := order["notes"].(map[string]interface{}) - if !ok { - slog.Error("razorpay order missing notes", "order_id", orderID, "payment_id", paymentID) - return - } - userIDStr, ok := notes["user_id"].(string) - if !ok || userIDStr == "" { - slog.Error("razorpay order notes missing user_id", "order_id", orderID, "payment_id", paymentID) - return - } - parsed, err := uuid.Parse(userIDStr) - if err != nil { - slog.Error("razorpay order notes user_id invalid", "error", err, "user_id", userIDStr, "order_id", orderID) - return - } - userID = parsed - planID, _ := notes["plan_id"].(string) - if planID == "developer-annual" { - period = "annual" - } - resolvedVia = "order_notes" - } - - slog.InfoContext(ctx, "payment.captured: promoting user", - "user_id", userID, "payment_id", paymentID, "sub_id", subID, "resolved_via", resolvedVia) - - // Promote the user's account tier first (independent of whether the - // payment entity carried a customer_id — in test mode it often doesn't). - // plan_paid_at records the most recent successful charge so the dashboard - // can show when the next renewal is expected. On the subscription path - // we also roll forward current_period_end and flip subscription_status - // to 'active' — webhooks for subscription.activated/.charged do this - // cleanly but the standalone payment.captured needs the same bookkeeping - // so Razorpay-dropped lifecycle events don't leave the user stuck at - // status='created'. - periodEnd := time.Now().AddDate(0, 1, 0).UTC() - if period == "annual" { - periodEnd = time.Now().AddDate(1, 0, 0).UTC() - } - if resolvedVia == "subscription_id" { - if _, err := s.db.ExecContext(ctx, - `UPDATE users SET plan_tier='paid', plan_period=$1, plan_paid_at=NOW(), - subscription_status='active', current_period_end=$2 - WHERE id = $3`, - period, periodEnd, userID, - ); err != nil { - slog.Error("failed to promote user (subscription path)", "error", err, "user_id", userID) - } - } else { - if _, err := s.db.ExecContext(ctx, - "UPDATE users SET plan_tier = 'paid', plan_period = $1, plan_paid_at = NOW() WHERE id = $2", - period, userID, - ); err != nil { - slog.Error("failed to promote user (order path)", "error", err, "user_id", userID) - } - } - - if customerID != "" { - if _, err := s.db.ExecContext(ctx, - "UPDATE users SET razorpay_customer_id = $1 WHERE id = $2", - customerID, userID, - ); err != nil { - slog.Error("failed to set razorpay_customer_id", "error", err, "user_id", userID, "customer_id", customerID) - } - } - - // Anonymous-flow atomic claim lives only on the legacy order path — the - // subscription flow collects the token server-side at create-subscription - // time and puts it in subscription.notes.token, which subscription.charged - // handles. Skip the lookup on the subscription path to avoid touching `notes` - // which is nil there. - if resolvedVia == "order_notes" { - if _, okNotesVar := map[string]interface{}{}["x"]; !okNotesVar { - // The variable `notes` is only in scope on the order path. Re-lookup - // via a fresh fetch would be wasteful; we already read notes above. - // This branch intentionally uses the outer `notes` captured in the - // fallback path. (Kept this comment so future readers don't move it.) - } - // handleLegacyNotesTokenClaim is inlined so we keep notes in scope: - client := newRazorpayClient(s.cfg.Razorpay) - order, err := client.Order.Fetch(orderID, nil, nil) - if err == nil { - if n, ok := order["notes"].(map[string]interface{}); ok { - if tokenStr, _ := n["token"].(string); tokenStr != "" { - if tokenUUID, err := uuid.Parse(tokenStr); err == nil { - if _, err := s.db.ExecContext(ctx, - `UPDATE resources SET migrated_to_user_id = $1, tier = 'paid', expires_at = NULL - WHERE token = $2 AND status = 'active'`, - userID, tokenUUID, - ); err != nil { - slog.Error("failed to claim token on payment", "error", err, "user_id", userID, "token", tokenStr) - } - } - } - } - } - } - - res, err := s.db.ExecContext(ctx, - "UPDATE resources SET tier = 'paid', expires_at = NULL WHERE migrated_to_user_id = $1 AND status = 'active'", - userID, - ) - if err != nil { - slog.Error("failed to promote resources to paid tier", "error", err, "user_id", userID) - return - } - affected, _ := res.RowsAffected() - slog.Info("razorpay payment captured; tier upgraded", - "user_id", userID, - "order_id", orderID, - "payment_id", paymentID, - "customer_id", customerID, - "resources_promoted", affected, - ) - - // Receipt email. Non-fatal — payment has already been committed to DB; - // a missing email is strictly a UX regression. The claim helper ensures - // we don't double-send when the reconciler also picks up this charge. - amountCents := 0 - if v, ok := entity["amount"].(float64); ok { - amountCents = int(v) - } - currency, _ := entity["currency"].(string) - sendReceiptIfUnsent(ctx, s.db, s.email, userID, amountCents, currency) -} - -func (s *server) computeSignature(payload, secret string) string { - h := hmac.New(sha256.New, []byte(secret)) - h.Write([]byte(payload)) - return hex.EncodeToString(h.Sum(nil)) -} - -// notifyPaymentFailed looks up the paying user's email via the Razorpay order's -// notes.user_id and fires off a "payment failed" email. Best-effort — any -// lookup failure is logged and the function returns without raising. -func (s *server) notifyPaymentFailed(ctx context.Context, orderID, reason string) { - if orderID == "" { - return - } - client := newRazorpayClient(s.cfg.Razorpay) - order, err := client.Order.Fetch(orderID, nil, nil) - if err != nil { - slog.Warn("payment_failed email: order fetch failed", "error", err, "order_id", orderID) - return - } - notes, _ := order["notes"].(map[string]interface{}) - userIDStr, _ := notes["user_id"].(string) - if userIDStr == "" { - return - } - userID, err := uuid.Parse(userIDStr) - if err != nil { - return - } - var email string - if err := s.db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email); err != nil || email == "" { - return - } - subject, html := paymentFailedEmail(reason) - s.email.SendAsync(email, subject, html) -} - -// ── Subscriptions (recurring billing) ─────────────────────────────────────── - -type CreateSubscriptionRequest struct { - Plan string `json:"plan"` // "monthly" | "annual" - Currency string `json:"currency,omitempty"` // "USD" | "INR" — empty ⇒ USD - Token string `json:"token,omitempty"` // optional anon resource token to claim on first charge -} - -type CreateSubscriptionResponse struct { - SubscriptionID string `json:"subscription_id"` - ShortURL string `json:"short_url"` - KeyID string `json:"key_id"` - PlanLabel string `json:"plan_label"` -} - -// handleCreateSubscription creates a Razorpay Subscription for the logged-in -// user and persists subscription_id + status='created' on the user row. The -// returned short_url can be used directly (hosted Razorpay page) or fed into -// Razorpay Checkout.js as options.subscription_id. -func (s *server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) { - user := s.authUser(r) - if user == nil { - writeError(w, http.StatusUnauthorized, "unauthorized", "Sign in required.") - return - } - - var req CreateSubscriptionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid_body", "Request body must be JSON.") - return - } - - // Reject unknown currencies up-front. Empty is allowed (falls back to USD). - if req.Currency != "" && !isSupportedCurrency(req.Currency) { - writeError(w, http.StatusBadRequest, "invalid_currency", "currency must be 'USD' or 'INR'.") - return - } - currency := normalizeCurrency(req.Currency) - - // Block double-subscribe: if the user has an active / pending subscription - // already, point them at cancel-then-resubscribe rather than silently - // creating a second one. Only when the prior sub is cancelled/completed/ - // halted is a new subscribe allowed. - if subscriptionStatusBlocksNew(user.SubscriptionStatus) { - writeError(w, http.StatusConflict, "already_subscribed", - "You already have a subscription. Cancel the current one before starting a new one.") - return - } - - // Currency lock-in: if the user already has a paid plan_currency (from a - // prior subscription cycle, even one that's since been cancelled), a new - // subscribe must stay in the same currency. Mixing is rejected — not to - // punish the user but because Razorpay plan ids are bound to a single - // currency and a USD subscriber's saved card likely can't charge INR. - if user.PlanCurrency != nil && *user.PlanCurrency != "" && *user.PlanCurrency != currency { - writeError(w, http.StatusBadRequest, "cannot_change_currency", - "Your account is already on a "+*user.PlanCurrency+" plan. Changing currency is not supported — contact support if you need to.") - return - } - - planID, planLabel, totalCount, ok := planConfig(req.Plan, currency, s.cfg.Razorpay) - if !ok { - writeError(w, http.StatusBadRequest, "invalid_plan", "plan must be 'monthly' or 'annual'.") - return - } - if planID == "" { - writeError(w, http.StatusServiceUnavailable, "plan_not_configured", "Billing is not fully configured — contact support.") - return - } - - notes := map[string]interface{}{ - "user_id": user.ID.String(), - "plan": req.Plan, - "currency": currency, - } - if req.Token != "" { - if _, err := uuid.Parse(req.Token); err == nil { - notes["token"] = req.Token - } - } - - // Razorpay SDK call with timing checkpoints so we can distinguish - // "SDK never returned" vs "Razorpay responded slowly" vs "outbound - // blocked at the container level" in production logs. - callStart := time.Now() - slog.InfoContext(r.Context(), "razorpay subscription create: starting", - "user_id", user.ID, "plan", req.Plan, "plan_id", planID) - - type subResult struct { - data map[string]interface{} - err error - } - resCh := make(chan subResult, 1) - go func() { - client := newRazorpayClient(s.cfg.Razorpay) - data, err := client.Subscription.Create(map[string]interface{}{ - "plan_id": planID, - "total_count": totalCount, - "customer_notify": 1, - "notes": notes, - }, nil) - resCh <- subResult{data: data, err: err} - }() - - var sub map[string]interface{} - select { - case res := <-resCh: - elapsed := time.Since(callStart) - if res.err != nil { - slog.ErrorContext(r.Context(), "razorpay subscription create failed", - "error", res.err, "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) - writeError(w, http.StatusBadGateway, "payment_gateway_error", - "Payment provider returned an error — please try again in a moment. If the problem persists, email contact@instanode.dev.") - return - } - slog.InfoContext(r.Context(), "razorpay subscription create: ok", - "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) - sub = res.data - case <-time.After(15 * time.Second): - slog.ErrorContext(r.Context(), "razorpay subscription create timeout", - "user_id", user.ID, "plan", req.Plan, "elapsed_ms", time.Since(callStart).Milliseconds()) - writeError(w, http.StatusGatewayTimeout, "payment_gateway_timeout", - "Payment provider took too long to respond. Please retry in a few seconds.") - return - } - - subID, _ := sub["id"].(string) - shortURL, _ := sub["short_url"].(string) - - ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - // Clear cancel_email_sent_at when a new subscription attaches so a later - // cancel on this fresh sub still triggers a cancellation email (the claim - // lock is per-sub-lifecycle, not lifetime). - // plan_currency uses COALESCE so the first subscription locks in the - // currency, and later re-subscribes (after a cancel) can't flip it — - // defence in depth behind the explicit check above. - if _, err := s.db.ExecContext(ctx, - `UPDATE users - SET razorpay_subscription_id = $1, - subscription_status = 'created', - plan_period = $2, - plan_currency = COALESCE(plan_currency, $3), - cancel_email_sent_at = NULL - WHERE id = $4`, - subID, req.Plan, currency, user.ID, - ); err != nil { - slog.ErrorContext(r.Context(), "persist subscription_id failed", "error", err, "user_id", user.ID, "sub_id", subID) - } - - writeJSON(w, http.StatusOK, CreateSubscriptionResponse{ - SubscriptionID: subID, - ShortURL: shortURL, - KeyID: s.cfg.Razorpay.KeyID, - PlanLabel: planLabel, - }) -} - -// handleSubscriptionCharged runs on both subscription.activated (first charge) -// and subscription.charged (recurring). Promotes the user to paid, rolls forward -// current_period_end, sends a receipt. -// -// Plan-switch branch: when notes.purpose == "plan_switch", this is the first -// charge on the *new* sub the reconciler created. We clear the pending_plan_* -// columns so the switch is marked complete, and fire the one-time -// planSwitchActivatedEmail via its claim helper. The normal receipt email -// still goes out below — the switch email is separate, content-wise. -func (s *server) handleSubscriptionCharged(ctx context.Context, subEntity, paymentEntity map[string]interface{}) { - subID, _ := subEntity["id"].(string) - if subID == "" { - return - } - notes, _ := subEntity["notes"].(map[string]interface{}) - userID, ok := userIDFromNotes(notes) - if !ok { - slog.Warn("subscription webhook: no user_id in notes", "sub_id", subID) - return - } - - periodEnd := unixToTime(subEntity["current_end"]) - period := periodFromSubscription(subEntity) - purpose, _ := notes["purpose"].(string) - isSwitchCharge := purpose == "plan_switch" - - // Read notes.currency defensively: legacy subs created before the dual- - // currency rollout have no such note; default to USD so we don't NULL out - // a previously-set plan_currency. COALESCE below keeps the lock-in - // invariant even if notes.currency is bogus. - planCurrency, _ := notes["currency"].(string) - planCurrency = normalizeCurrency(planCurrency) - - if _, err := s.db.ExecContext(ctx, - `UPDATE users - SET plan_tier = 'paid', - plan_period = $1, - plan_paid_at = NOW(), - razorpay_subscription_id = $2, - subscription_status = 'active', - current_period_end = $3, - plan_currency = COALESCE(plan_currency, $4) - WHERE id = $5`, - period, subID, periodEnd, planCurrency, userID, - ); err != nil { - slog.Error("subscription.charged: user update failed", "error", err, "user_id", userID, "sub_id", subID) - return - } - - // Plan-switch activation: clear pending_* columns so the switch is marked - // complete on our side. Done as a separate UPDATE so the main promotion - // UPDATE above (which is idempotent across renewals) stays unchanged on - // recurring charges. The claim helper below is what atomically sends the - // "you're now on " email — safe to call even when another caller - // (reconciler sweep) just did the same. - if isSwitchCharge { - if _, err := s.db.ExecContext(ctx, - `UPDATE users - SET pending_plan_change = NULL, - pending_plan_effective_at = NULL, - pending_plan_sub_id = NULL - WHERE id = $1`, - userID, - ); err != nil { - slog.Error("subscription.charged (plan_switch): clear pending failed", - "error", err, "user_id", userID, "sub_id", subID) - } - sendPlanSwitchActivatedIfUnsent(ctx, s.db, s.email, userID) - } - - // Claim any pre-payment anon token captured in notes.token — same semantics - // as the old payment.captured path. - if tokenStr, _ := notes["token"].(string); tokenStr != "" { - if tokenUUID, err := uuid.Parse(tokenStr); err == nil { - s.db.ExecContext(ctx, - `UPDATE resources SET migrated_to_user_id = $1, tier = 'paid', expires_at = NULL - WHERE token = $2 AND status = 'active'`, - userID, tokenUUID, - ) - } - } - // Promote every active resource belonging to the user. - s.db.ExecContext(ctx, - "UPDATE resources SET tier = 'paid', expires_at = NULL WHERE migrated_to_user_id = $1 AND status = 'active'", - userID, - ) - - // Receipt email — claim-locked so a retried webhook or a simultaneous - // reconciler tick can't double-send. - amountCents := 0 - if v, ok := paymentEntity["amount"].(float64); ok { - amountCents = int(v) - } - currency, _ := paymentEntity["currency"].(string) - sendReceiptIfUnsent(ctx, s.db, s.email, userID, amountCents, currency) - - slog.Info("subscription charged", "user_id", userID, "sub_id", subID, "period_end", periodEnd.Format(time.RFC3339)) -} - -// handleSubscriptionHalted — Razorpay gave up after retry policy exhausted. -// Downgrade the user to free tier; existing anon-claimed resources keep working -// until their TTL (resources stay tier='paid' on the row — no scary data loss -// on billing failure; operator can reach out before yanking access). -func (s *server) handleSubscriptionHalted(ctx context.Context, subEntity map[string]interface{}) { - subID, _ := subEntity["id"].(string) - notes, _ := subEntity["notes"].(map[string]interface{}) - userID, ok := userIDFromNotes(notes) - if !ok { - return - } - s.db.ExecContext(ctx, - "UPDATE users SET subscription_status = 'halted', plan_tier = 'free' WHERE id = $1", - userID, - ) - var email string - if err := s.db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email); err == nil && email != "" { - subject, html := paymentFailedEmail("Your subscription has been halted after multiple failed charge attempts.") - s.email.SendAsync(email, subject, html) - } - slog.Warn("subscription halted", "user_id", userID, "sub_id", subID) -} - -// handleSubscriptionCancelled fires when a subscription is cancelled — whether -// via our API, the Razorpay dashboard, or Razorpay's own lifecycle. User -// resolution falls back to sub_id lookup because dashboard-initiated cancels -// sometimes arrive without our notes attached. Sending the cancellation email -// is claim-locked so a reconciler sweep for the same sub can't double-send. -func (s *server) handleSubscriptionCancelled(ctx context.Context, subEntity map[string]interface{}) { - subID, _ := subEntity["id"].(string) - notes, _ := subEntity["notes"].(map[string]interface{}) - - var userID uuid.UUID - if id, ok := userIDFromNotes(notes); ok { - userID = id - } else if subID != "" { - if err := s.db.QueryRowContext(ctx, - "SELECT id FROM users WHERE razorpay_subscription_id = $1", subID, - ).Scan(&userID); err != nil { - slog.Warn("subscription.cancelled: cannot resolve user", "sub_id", subID, "error", err) - return - } - } else { - return - } - - periodEnd := unixToTime(subEntity["current_end"]) - // An outright cancel takes precedence over a pending plan switch — if the - // user (or Razorpay) cancels the current sub, we don't want the reconciler - // to then fire a *new* sub for the switch they're walking away from. Clear - // the pending_plan_* columns in the same UPDATE so the abandonment is atomic. - if periodEnd.IsZero() { - if _, err := s.db.ExecContext(ctx, - `UPDATE users - SET subscription_status = 'cancelled', - pending_plan_change = NULL, - pending_plan_effective_at = NULL, - pending_plan_sub_id = NULL - WHERE id = $1`, - userID, - ); err != nil { - slog.Error("subscription.cancelled: persist failed", "error", err, "user_id", userID) - return - } - } else { - if _, err := s.db.ExecContext(ctx, - `UPDATE users - SET subscription_status = 'cancelled', - current_period_end = $1, - pending_plan_change = NULL, - pending_plan_effective_at = NULL, - pending_plan_sub_id = NULL - WHERE id = $2`, - periodEnd, userID, - ); err != nil { - slog.Error("subscription.cancelled: persist failed", "error", err, "user_id", userID) - return - } - } - - sendCancelIfUnsent(ctx, s.db, s.email, userID) - slog.Info("subscription cancelled", "user_id", userID, "sub_id", subID) -} - -func (s *server) handleSubscriptionCompleted(ctx context.Context, subEntity map[string]interface{}) { - subID, _ := subEntity["id"].(string) - notes, _ := subEntity["notes"].(map[string]interface{}) - userID, ok := userIDFromNotes(notes) - if !ok { - return - } - s.db.ExecContext(ctx, - "UPDATE users SET subscription_status = 'completed' WHERE id = $1", - userID, - ) - slog.Info("subscription completed", "user_id", userID, "sub_id", subID) -} - -// ── Small helpers ─────────────────────────────────────────────────────────── +// ── Shared helpers used across orders / webhook / subscriptions ───────────── func userIDFromNotes(notes map[string]interface{}) (uuid.UUID, bool) { v, _ := notes["user_id"].(string) diff --git a/internal/server/billing_orders.go b/internal/server/billing_orders.go new file mode 100644 index 0000000..fe3c9fa --- /dev/null +++ b/internal/server/billing_orders.go @@ -0,0 +1,121 @@ +package server + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/google/uuid" +) + +type CreateOrderRequest struct { + PlanID string `json:"plan_id"` // e.g., "developer" + Currency string `json:"currency"` // "USD" | "EUR" | "GBP" | "INR" + Token string `json:"token,omitempty"` // optional anon resource token to upgrade atomically on payment +} + +type CreateOrderResponse struct { + OrderID string `json:"order_id"` + Amount int `json:"amount"` + Currency string `json:"currency"` + KeyID string `json:"key_id"` + Name string `json:"name"` + Email string `json:"email"` + Contact string `json:"contact"` +} + +// planPricing holds minor-unit amounts (cents / paise) per currency. +// Monthly Developer: $12. Annual Developer: $120 (two months free). +// INR prices mirror the USD ratio — ₹999/mo and ₹9,990/yr. +var planPricing = map[string]map[string]int{ + "developer": { + "USD": 1200, + "EUR": 1200, + "GBP": 1200, + "INR": 99900, + }, + "developer-annual": { + "USD": 12000, + "EUR": 12000, + "GBP": 12000, + "INR": 999000, + }, +} + +func (s *server) handleCreateOrder(w http.ResponseWriter, r *http.Request) { + // Note: no direct platform-PG calls here; authUser handles its own 5s + // timeout internally. The only external call is client.Order.Create + // below, which the Razorpay Go SDK runs without context support — see + // comment at that call site. + user := s.authUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Sign in required.") + return + } + + var req CreateOrderRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Request body must be JSON.") + return + } + + if req.Currency == "" { + req.Currency = "USD" + } + + currencies, ok := planPricing[req.PlanID] + if !ok { + writeError(w, http.StatusBadRequest, "invalid_plan", "Unknown plan_id.") + return + } + amount, ok := currencies[req.Currency] + if !ok { + writeError(w, http.StatusBadRequest, "invalid_currency", "Supported currencies: USD, EUR, GBP, INR.") + return + } + + client := newRazorpayClient(s.cfg.Razorpay) + + notes := map[string]interface{}{ + "user_id": user.ID.String(), + "plan_id": req.PlanID, + } + if req.Token != "" { + if _, err := uuid.Parse(req.Token); err == nil { + notes["token"] = req.Token + } + } + + data := map[string]interface{}{ + "amount": amount, + "currency": req.Currency, + "receipt": uuid.New().String(), + "payment_capture": 1, + "notes": notes, + } + + // LIMITATION: the Razorpay Go SDK does not accept a context.Context here, + // so we cannot enforce our 5s request budget on this call. It will stall + // up to Razorpay's own SDK-internal HTTP timeout (currently unbounded in + // razorpay-go). If this becomes a production hang risk, wrap with a + // channel + time.After pattern and abandon the goroutine on timeout. + order, err := client.Order.Create(data, nil) + if err != nil { + slog.ErrorContext(r.Context(), "razorpay order create failed", "error", err, "user_id", user.ID, "plan", req.PlanID, "currency", req.Currency) + writeError(w, http.StatusBadGateway, "payment_gateway_error", "Payment provider is unavailable — please try again in a moment.") + return + } + + response := CreateOrderResponse{ + OrderID: order["id"].(string), + Amount: amount, + Currency: req.Currency, + KeyID: s.cfg.Razorpay.KeyID, + Name: "InstaNode User", + Email: user.Email, + Contact: "", // Optional + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/internal/server/billing_subscriptions.go b/internal/server/billing_subscriptions.go new file mode 100644 index 0000000..51c558f --- /dev/null +++ b/internal/server/billing_subscriptions.go @@ -0,0 +1,364 @@ +package server + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/google/uuid" +) + +type CreateSubscriptionRequest struct { + Plan string `json:"plan"` // "monthly" | "annual" + Currency string `json:"currency,omitempty"` // "USD" | "INR" — empty ⇒ USD + Token string `json:"token,omitempty"` // optional anon resource token to claim on first charge +} + +type CreateSubscriptionResponse struct { + SubscriptionID string `json:"subscription_id"` + ShortURL string `json:"short_url"` + KeyID string `json:"key_id"` + PlanLabel string `json:"plan_label"` +} + +// handleCreateSubscription creates a Razorpay Subscription for the logged-in +// user and persists subscription_id + status='created' on the user row. The +// returned short_url can be used directly (hosted Razorpay page) or fed into +// Razorpay Checkout.js as options.subscription_id. +func (s *server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) { + user := s.authUser(r) + if user == nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "Sign in required.") + return + } + + var req CreateSubscriptionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Request body must be JSON.") + return + } + + // Reject unknown currencies up-front. Empty is allowed (falls back to USD). + if req.Currency != "" && !isSupportedCurrency(req.Currency) { + writeError(w, http.StatusBadRequest, "invalid_currency", "currency must be 'USD' or 'INR'.") + return + } + currency := normalizeCurrency(req.Currency) + + // Block double-subscribe: if the user has an active / pending subscription + // already, point them at cancel-then-resubscribe rather than silently + // creating a second one. Only when the prior sub is cancelled/completed/ + // halted is a new subscribe allowed. + if subscriptionStatusBlocksNew(user.SubscriptionStatus) { + writeError(w, http.StatusConflict, "already_subscribed", + "You already have a subscription. Cancel the current one before starting a new one.") + return + } + + // Currency lock-in: if the user already has a paid plan_currency (from a + // prior subscription cycle, even one that's since been cancelled), a new + // subscribe must stay in the same currency. Mixing is rejected — not to + // punish the user but because Razorpay plan ids are bound to a single + // currency and a USD subscriber's saved card likely can't charge INR. + if user.PlanCurrency != nil && *user.PlanCurrency != "" && *user.PlanCurrency != currency { + writeError(w, http.StatusBadRequest, "cannot_change_currency", + "Your account is already on a "+*user.PlanCurrency+" plan. Changing currency is not supported — contact support if you need to.") + return + } + + planID, planLabel, totalCount, ok := planConfig(req.Plan, currency, s.cfg.Razorpay) + if !ok { + writeError(w, http.StatusBadRequest, "invalid_plan", "plan must be 'monthly' or 'annual'.") + return + } + if planID == "" { + writeError(w, http.StatusServiceUnavailable, "plan_not_configured", "Billing is not fully configured — contact support.") + return + } + + notes := map[string]interface{}{ + "user_id": user.ID.String(), + "plan": req.Plan, + "currency": currency, + } + if req.Token != "" { + if _, err := uuid.Parse(req.Token); err == nil { + notes["token"] = req.Token + } + } + + // Razorpay SDK call with timing checkpoints so we can distinguish + // "SDK never returned" vs "Razorpay responded slowly" vs "outbound + // blocked at the container level" in production logs. + callStart := time.Now() + slog.InfoContext(r.Context(), "razorpay subscription create: starting", + "user_id", user.ID, "plan", req.Plan, "plan_id", planID) + + type subResult struct { + data map[string]interface{} + err error + } + resCh := make(chan subResult, 1) + go func() { + client := newRazorpayClient(s.cfg.Razorpay) + data, err := client.Subscription.Create(map[string]interface{}{ + "plan_id": planID, + "total_count": totalCount, + "customer_notify": 1, + "notes": notes, + }, nil) + resCh <- subResult{data: data, err: err} + }() + + var sub map[string]interface{} + select { + case res := <-resCh: + elapsed := time.Since(callStart) + if res.err != nil { + slog.ErrorContext(r.Context(), "razorpay subscription create failed", + "error", res.err, "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) + writeError(w, http.StatusBadGateway, "payment_gateway_error", + "Payment provider returned an error — please try again in a moment. If the problem persists, email contact@instanode.dev.") + return + } + slog.InfoContext(r.Context(), "razorpay subscription create: ok", + "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) + sub = res.data + case <-time.After(15 * time.Second): + slog.ErrorContext(r.Context(), "razorpay subscription create timeout", + "user_id", user.ID, "plan", req.Plan, "elapsed_ms", time.Since(callStart).Milliseconds()) + writeError(w, http.StatusGatewayTimeout, "payment_gateway_timeout", + "Payment provider took too long to respond. Please retry in a few seconds.") + return + } + + subID, _ := sub["id"].(string) + shortURL, _ := sub["short_url"].(string) + + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + // Clear cancel_email_sent_at when a new subscription attaches so a later + // cancel on this fresh sub still triggers a cancellation email (the claim + // lock is per-sub-lifecycle, not lifetime). + // plan_currency uses COALESCE so the first subscription locks in the + // currency, and later re-subscribes (after a cancel) can't flip it — + // defence in depth behind the explicit check above. + if _, err := s.db.ExecContext(ctx, + `UPDATE users + SET razorpay_subscription_id = $1, + subscription_status = 'created', + plan_period = $2, + plan_currency = COALESCE(plan_currency, $3), + cancel_email_sent_at = NULL + WHERE id = $4`, + subID, req.Plan, currency, user.ID, + ); err != nil { + slog.ErrorContext(r.Context(), "persist subscription_id failed", "error", err, "user_id", user.ID, "sub_id", subID) + } + + writeJSON(w, http.StatusOK, CreateSubscriptionResponse{ + SubscriptionID: subID, + ShortURL: shortURL, + KeyID: s.cfg.Razorpay.KeyID, + PlanLabel: planLabel, + }) +} + +// handleSubscriptionCharged runs on both subscription.activated (first charge) +// and subscription.charged (recurring). Promotes the user to paid, rolls forward +// current_period_end, sends a receipt. +// +// Plan-switch branch: when notes.purpose == "plan_switch", this is the first +// charge on the *new* sub the reconciler created. We clear the pending_plan_* +// columns so the switch is marked complete, and fire the one-time +// planSwitchActivatedEmail via its claim helper. The normal receipt email +// still goes out below — the switch email is separate, content-wise. +func (s *server) handleSubscriptionCharged(ctx context.Context, subEntity, paymentEntity map[string]interface{}) { + subID, _ := subEntity["id"].(string) + if subID == "" { + return + } + notes, _ := subEntity["notes"].(map[string]interface{}) + userID, ok := userIDFromNotes(notes) + if !ok { + slog.Warn("subscription webhook: no user_id in notes", "sub_id", subID) + return + } + + periodEnd := unixToTime(subEntity["current_end"]) + period := periodFromSubscription(subEntity) + purpose, _ := notes["purpose"].(string) + isSwitchCharge := purpose == "plan_switch" + + // Read notes.currency defensively: legacy subs created before the dual- + // currency rollout have no such note; default to USD so we don't NULL out + // a previously-set plan_currency. COALESCE below keeps the lock-in + // invariant even if notes.currency is bogus. + planCurrency, _ := notes["currency"].(string) + planCurrency = normalizeCurrency(planCurrency) + + if _, err := s.db.ExecContext(ctx, + `UPDATE users + SET plan_tier = 'paid', + plan_period = $1, + plan_paid_at = NOW(), + razorpay_subscription_id = $2, + subscription_status = 'active', + current_period_end = $3, + plan_currency = COALESCE(plan_currency, $4) + WHERE id = $5`, + period, subID, periodEnd, planCurrency, userID, + ); err != nil { + slog.Error("subscription.charged: user update failed", "error", err, "user_id", userID, "sub_id", subID) + return + } + + // Plan-switch activation: clear pending_* columns so the switch is marked + // complete on our side. Done as a separate UPDATE so the main promotion + // UPDATE above (which is idempotent across renewals) stays unchanged on + // recurring charges. The claim helper below is what atomically sends the + // "you're now on " email — safe to call even when another caller + // (reconciler sweep) just did the same. + if isSwitchCharge { + if _, err := s.db.ExecContext(ctx, + `UPDATE users + SET pending_plan_change = NULL, + pending_plan_effective_at = NULL, + pending_plan_sub_id = NULL + WHERE id = $1`, + userID, + ); err != nil { + slog.Error("subscription.charged (plan_switch): clear pending failed", + "error", err, "user_id", userID, "sub_id", subID) + } + sendPlanSwitchActivatedIfUnsent(ctx, s.db, s.email, userID) + } + + // Claim any pre-payment anon token captured in notes.token — same semantics + // as the old payment.captured path. + if tokenStr, _ := notes["token"].(string); tokenStr != "" { + if tokenUUID, err := uuid.Parse(tokenStr); err == nil { + s.db.ExecContext(ctx, + `UPDATE resources SET migrated_to_user_id = $1, tier = 'paid', expires_at = NULL + WHERE token = $2 AND status = 'active'`, + userID, tokenUUID, + ) + } + } + // Promote every active resource belonging to the user. + s.db.ExecContext(ctx, + "UPDATE resources SET tier = 'paid', expires_at = NULL WHERE migrated_to_user_id = $1 AND status = 'active'", + userID, + ) + + // Receipt email — claim-locked so a retried webhook or a simultaneous + // reconciler tick can't double-send. + amountCents := 0 + if v, ok := paymentEntity["amount"].(float64); ok { + amountCents = int(v) + } + currency, _ := paymentEntity["currency"].(string) + sendReceiptIfUnsent(ctx, s.db, s.email, userID, amountCents, currency) + + slog.Info("subscription charged", "user_id", userID, "sub_id", subID, "period_end", periodEnd.Format(time.RFC3339)) +} + +// handleSubscriptionHalted — Razorpay gave up after retry policy exhausted. +// Downgrade the user to free tier; existing anon-claimed resources keep working +// until their TTL (resources stay tier='paid' on the row — no scary data loss +// on billing failure; operator can reach out before yanking access). +func (s *server) handleSubscriptionHalted(ctx context.Context, subEntity map[string]interface{}) { + subID, _ := subEntity["id"].(string) + notes, _ := subEntity["notes"].(map[string]interface{}) + userID, ok := userIDFromNotes(notes) + if !ok { + return + } + s.db.ExecContext(ctx, + "UPDATE users SET subscription_status = 'halted', plan_tier = 'free' WHERE id = $1", + userID, + ) + var email string + if err := s.db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email); err == nil && email != "" { + subject, html := paymentFailedEmail("Your subscription has been halted after multiple failed charge attempts.") + s.email.SendAsync(email, subject, html) + } + slog.Warn("subscription halted", "user_id", userID, "sub_id", subID) +} + +// handleSubscriptionCancelled fires when a subscription is cancelled — whether +// via our API, the Razorpay dashboard, or Razorpay's own lifecycle. User +// resolution falls back to sub_id lookup because dashboard-initiated cancels +// sometimes arrive without our notes attached. Sending the cancellation email +// is claim-locked so a reconciler sweep for the same sub can't double-send. +func (s *server) handleSubscriptionCancelled(ctx context.Context, subEntity map[string]interface{}) { + subID, _ := subEntity["id"].(string) + notes, _ := subEntity["notes"].(map[string]interface{}) + + var userID uuid.UUID + if id, ok := userIDFromNotes(notes); ok { + userID = id + } else if subID != "" { + if err := s.db.QueryRowContext(ctx, + "SELECT id FROM users WHERE razorpay_subscription_id = $1", subID, + ).Scan(&userID); err != nil { + slog.Warn("subscription.cancelled: cannot resolve user", "sub_id", subID, "error", err) + return + } + } else { + return + } + + periodEnd := unixToTime(subEntity["current_end"]) + // An outright cancel takes precedence over a pending plan switch — if the + // user (or Razorpay) cancels the current sub, we don't want the reconciler + // to then fire a *new* sub for the switch they're walking away from. Clear + // the pending_plan_* columns in the same UPDATE so the abandonment is atomic. + if periodEnd.IsZero() { + if _, err := s.db.ExecContext(ctx, + `UPDATE users + SET subscription_status = 'cancelled', + pending_plan_change = NULL, + pending_plan_effective_at = NULL, + pending_plan_sub_id = NULL + WHERE id = $1`, + userID, + ); err != nil { + slog.Error("subscription.cancelled: persist failed", "error", err, "user_id", userID) + return + } + } else { + if _, err := s.db.ExecContext(ctx, + `UPDATE users + SET subscription_status = 'cancelled', + current_period_end = $1, + pending_plan_change = NULL, + pending_plan_effective_at = NULL, + pending_plan_sub_id = NULL + WHERE id = $2`, + periodEnd, userID, + ); err != nil { + slog.Error("subscription.cancelled: persist failed", "error", err, "user_id", userID) + return + } + } + + sendCancelIfUnsent(ctx, s.db, s.email, userID) + slog.Info("subscription cancelled", "user_id", userID, "sub_id", subID) +} + +func (s *server) handleSubscriptionCompleted(ctx context.Context, subEntity map[string]interface{}) { + subID, _ := subEntity["id"].(string) + notes, _ := subEntity["notes"].(map[string]interface{}) + userID, ok := userIDFromNotes(notes) + if !ok { + return + } + s.db.ExecContext(ctx, + "UPDATE users SET subscription_status = 'completed' WHERE id = $1", + userID, + ) + slog.Info("subscription completed", "user_id", userID, "sub_id", subID) +} diff --git a/internal/server/billing_webhook.go b/internal/server/billing_webhook.go new file mode 100644 index 0000000..edd0880 --- /dev/null +++ b/internal/server/billing_webhook.go @@ -0,0 +1,340 @@ +package server + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "time" + + "github.com/google/uuid" +) + +func (s *server) handleRazorpayWebhook(w http.ResponseWriter, r *http.Request) { + // Bound platform-PG dedup insert + downstream handlePaymentCaptured calls + // to 5s so a stuck platform-PG can't hang this request. We intentionally + // pick the request's 5s budget rather than the full Razorpay retry window + // — Razorpay will retry on our 500, which is safer than a hung handler. + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + body, err := io.ReadAll(r.Body) + if err != nil { + slog.ErrorContext(r.Context(), "razorpay webhook: body read failed", "error", err) + writeError(w, http.StatusBadRequest, "invalid_body", "Could not read request body.") + return + } + + signature := r.Header.Get("X-Razorpay-Signature") + expectedSignature := s.computeSignature(string(body), s.cfg.Razorpay.WebhookSecret) + if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + slog.WarnContext(r.Context(), "razorpay webhook: signature mismatch") + writeError(w, http.StatusUnauthorized, "invalid_signature", "Signature verification failed.") + return + } + + var event map[string]interface{} + if err := json.Unmarshal(body, &event); err != nil { + slog.WarnContext(r.Context(), "razorpay webhook: invalid JSON", "error", err) + writeError(w, http.StatusBadRequest, "invalid_json", "Body is not valid JSON.") + return + } + + eventType, ok := event["event"].(string) + if !ok { + slog.WarnContext(r.Context(), "razorpay webhook: missing event type") + writeError(w, http.StatusBadRequest, "missing_event", "Payload has no 'event' field.") + return + } + + // Idempotency key: prefer Razorpay's event-level id when present (every + // webhook carries one); fall back to payment/subscription entity ids. + payload, _ := event["payload"].(map[string]interface{}) + paymentMap, _ := payload["payment"].(map[string]interface{}) + paymentEntity, _ := paymentMap["entity"].(map[string]interface{}) + subMap, _ := payload["subscription"].(map[string]interface{}) + subEntity, _ := subMap["entity"].(map[string]interface{}) + + dedupID, _ := event["id"].(string) + if dedupID == "" { + if p, ok := paymentEntity["id"].(string); ok { + dedupID = p + } else if s, ok := subEntity["id"].(string); ok { + dedupID = s + ":" + eventType + } + } + if dedupID == "" { + slog.Warn("razorpay webhook: no dedup id", "event", eventType) + w.WriteHeader(http.StatusOK) + return + } + + // Idempotency: record the dedup id; if it was already seen, no-op. + res, err := s.db.ExecContext(ctx, + "INSERT INTO processed_webhooks (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING", + dedupID, + ) + if err != nil { + slog.ErrorContext(r.Context(), "webhook dedup insert failed", "error", err, "dedup_id", dedupID) + writeError(w, http.StatusInternalServerError, "internal_error", "Could not process webhook — please retry.") + return + } + rows, _ := res.RowsAffected() + if rows == 0 { + slog.Info("razorpay webhook already processed; skipping", "dedup_id", dedupID, "event", eventType) + w.WriteHeader(http.StatusOK) + return + } + + switch eventType { + case "payment.captured": + s.handlePaymentCaptured(ctx, paymentEntity, dedupID) + case "payment.failed": + orderID, _ := paymentEntity["order_id"].(string) + subID, _ := paymentEntity["subscription_id"].(string) + reason, _ := paymentEntity["error_description"].(string) + if reason == "" { + reason, _ = paymentEntity["error_reason"].(string) + } + slog.Warn("razorpay payment failed", "dedup_id", dedupID, "order_id", orderID, "sub_id", subID, "reason", reason) + s.notifyPaymentFailed(ctx, orderID, reason) + // If this failure is the first charge on a subscription, the user's + // subscription row in our DB is stuck at status='created' with a + // sub_id pointing at a Razorpay subscription that'll never activate. + // Clear it so their next Subscribe click starts clean — otherwise + // the already_subscribed guard traps them indefinitely. + if subID != "" { + if _, err := s.db.ExecContext(ctx, + `UPDATE users SET razorpay_subscription_id = NULL, subscription_status = NULL + WHERE razorpay_subscription_id = $1`, subID); err != nil { + slog.WarnContext(ctx, "clear stuck subscription after payment.failed", "error", err, "sub_id", subID) + } + } + case "subscription.activated", "subscription.charged": + s.handleSubscriptionCharged(ctx, subEntity, paymentEntity) + case "subscription.halted": + s.handleSubscriptionHalted(ctx, subEntity) + case "subscription.cancelled": + s.handleSubscriptionCancelled(ctx, subEntity) + case "subscription.completed": + s.handleSubscriptionCompleted(ctx, subEntity) + case "subscription.authenticated", "subscription.pending", "subscription.paused", "subscription.resumed": + slog.Info("razorpay subscription lifecycle event", "event", eventType, "sub_id", subEntity["id"]) + default: + slog.Info("razorpay webhook event ignored", "event", eventType, "dedup_id", dedupID) + } + + w.WriteHeader(http.StatusOK) +} + +// handlePaymentCaptured promotes the paying user's resources to the paid tier. +// Errors are logged but not returned — we've already recorded the payment id as +// processed, so returning 200 is correct; operator alerts pick up the log. +// ctx is the 5s-bounded request context from handleRazorpayWebhook. +// +// User resolution has two paths: +// 1. Legacy one-time Orders flow: order carries notes.user_id (set by our +// /billing/create-order handler). Fetch order, read notes. +// 2. Subscription flow: the payment entity has subscription_id but the +// auto-generated order carries no notes. Look up the subscription_id in +// our users table to resolve the owner. +func (s *server) handlePaymentCaptured(ctx context.Context, entity map[string]interface{}, paymentID string) { + customerID, _ := entity["customer_id"].(string) + subID, _ := entity["subscription_id"].(string) + orderID, _ := entity["order_id"].(string) + + var userID uuid.UUID + period := "monthly" + resolvedVia := "" + + // Subscription path first — the payment entity tells us this is a + // subscription charge, so skip the order-notes lookup (which would fail). + if subID != "" { + err := s.db.QueryRowContext(ctx, + "SELECT id, COALESCE(plan_period,'monthly') FROM users WHERE razorpay_subscription_id = $1", + subID, + ).Scan(&userID, &period) + if err == nil { + resolvedVia = "subscription_id" + } else { + slog.Warn("payment.captured: subscription lookup failed; falling back to order notes", + "error", err, "sub_id", subID, "payment_id", paymentID) + } + } + + // Order-notes fallback (legacy one-time-order checkout path). + if resolvedVia == "" { + if orderID == "" { + slog.Error("payment.captured missing order_id and unresolvable subscription_id", "payment_id", paymentID) + return + } + client := newRazorpayClient(s.cfg.Razorpay) + order, err := client.Order.Fetch(orderID, nil, nil) + if err != nil { + slog.Error("razorpay order fetch failed", "error", err, "order_id", orderID, "payment_id", paymentID) + return + } + notes, ok := order["notes"].(map[string]interface{}) + if !ok { + slog.Error("razorpay order missing notes", "order_id", orderID, "payment_id", paymentID) + return + } + userIDStr, ok := notes["user_id"].(string) + if !ok || userIDStr == "" { + slog.Error("razorpay order notes missing user_id", "order_id", orderID, "payment_id", paymentID) + return + } + parsed, err := uuid.Parse(userIDStr) + if err != nil { + slog.Error("razorpay order notes user_id invalid", "error", err, "user_id", userIDStr, "order_id", orderID) + return + } + userID = parsed + planID, _ := notes["plan_id"].(string) + if planID == "developer-annual" { + period = "annual" + } + resolvedVia = "order_notes" + } + + slog.InfoContext(ctx, "payment.captured: promoting user", + "user_id", userID, "payment_id", paymentID, "sub_id", subID, "resolved_via", resolvedVia) + + // Promote the user's account tier first (independent of whether the + // payment entity carried a customer_id — in test mode it often doesn't). + // plan_paid_at records the most recent successful charge so the dashboard + // can show when the next renewal is expected. On the subscription path + // we also roll forward current_period_end and flip subscription_status + // to 'active' — webhooks for subscription.activated/.charged do this + // cleanly but the standalone payment.captured needs the same bookkeeping + // so Razorpay-dropped lifecycle events don't leave the user stuck at + // status='created'. + periodEnd := time.Now().AddDate(0, 1, 0).UTC() + if period == "annual" { + periodEnd = time.Now().AddDate(1, 0, 0).UTC() + } + if resolvedVia == "subscription_id" { + if _, err := s.db.ExecContext(ctx, + `UPDATE users SET plan_tier='paid', plan_period=$1, plan_paid_at=NOW(), + subscription_status='active', current_period_end=$2 + WHERE id = $3`, + period, periodEnd, userID, + ); err != nil { + slog.Error("failed to promote user (subscription path)", "error", err, "user_id", userID) + } + } else { + if _, err := s.db.ExecContext(ctx, + "UPDATE users SET plan_tier = 'paid', plan_period = $1, plan_paid_at = NOW() WHERE id = $2", + period, userID, + ); err != nil { + slog.Error("failed to promote user (order path)", "error", err, "user_id", userID) + } + } + + if customerID != "" { + if _, err := s.db.ExecContext(ctx, + "UPDATE users SET razorpay_customer_id = $1 WHERE id = $2", + customerID, userID, + ); err != nil { + slog.Error("failed to set razorpay_customer_id", "error", err, "user_id", userID, "customer_id", customerID) + } + } + + // Anonymous-flow atomic claim lives only on the legacy order path — the + // subscription flow collects the token server-side at create-subscription + // time and puts it in subscription.notes.token, which subscription.charged + // handles. Skip the lookup on the subscription path to avoid touching `notes` + // which is nil there. + if resolvedVia == "order_notes" { + if _, okNotesVar := map[string]interface{}{}["x"]; !okNotesVar { + // The variable `notes` is only in scope on the order path. Re-lookup + // via a fresh fetch would be wasteful; we already read notes above. + // This branch intentionally uses the outer `notes` captured in the + // fallback path. (Kept this comment so future readers don't move it.) + } + // handleLegacyNotesTokenClaim is inlined so we keep notes in scope: + client := newRazorpayClient(s.cfg.Razorpay) + order, err := client.Order.Fetch(orderID, nil, nil) + if err == nil { + if n, ok := order["notes"].(map[string]interface{}); ok { + if tokenStr, _ := n["token"].(string); tokenStr != "" { + if tokenUUID, err := uuid.Parse(tokenStr); err == nil { + if _, err := s.db.ExecContext(ctx, + `UPDATE resources SET migrated_to_user_id = $1, tier = 'paid', expires_at = NULL + WHERE token = $2 AND status = 'active'`, + userID, tokenUUID, + ); err != nil { + slog.Error("failed to claim token on payment", "error", err, "user_id", userID, "token", tokenStr) + } + } + } + } + } + } + + res, err := s.db.ExecContext(ctx, + "UPDATE resources SET tier = 'paid', expires_at = NULL WHERE migrated_to_user_id = $1 AND status = 'active'", + userID, + ) + if err != nil { + slog.Error("failed to promote resources to paid tier", "error", err, "user_id", userID) + return + } + affected, _ := res.RowsAffected() + slog.Info("razorpay payment captured; tier upgraded", + "user_id", userID, + "order_id", orderID, + "payment_id", paymentID, + "customer_id", customerID, + "resources_promoted", affected, + ) + + // Receipt email. Non-fatal — payment has already been committed to DB; + // a missing email is strictly a UX regression. The claim helper ensures + // we don't double-send when the reconciler also picks up this charge. + amountCents := 0 + if v, ok := entity["amount"].(float64); ok { + amountCents = int(v) + } + currency, _ := entity["currency"].(string) + sendReceiptIfUnsent(ctx, s.db, s.email, userID, amountCents, currency) +} + +func (s *server) computeSignature(payload, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write([]byte(payload)) + return hex.EncodeToString(h.Sum(nil)) +} + +// notifyPaymentFailed looks up the paying user's email via the Razorpay order's +// notes.user_id and fires off a "payment failed" email. Best-effort — any +// lookup failure is logged and the function returns without raising. +func (s *server) notifyPaymentFailed(ctx context.Context, orderID, reason string) { + if orderID == "" { + return + } + client := newRazorpayClient(s.cfg.Razorpay) + order, err := client.Order.Fetch(orderID, nil, nil) + if err != nil { + slog.Warn("payment_failed email: order fetch failed", "error", err, "order_id", orderID) + return + } + notes, _ := order["notes"].(map[string]interface{}) + userIDStr, _ := notes["user_id"].(string) + if userIDStr == "" { + return + } + userID, err := uuid.Parse(userIDStr) + if err != nil { + return + } + var email string + if err := s.db.QueryRowContext(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email); err != nil || email == "" { + return + } + subject, html := paymentFailedEmail(reason) + s.email.SendAsync(email, subject, html) +} From 66c8f597b06f189d78838ea4d2901f5507c34c3d Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 23 Apr 2026 22:07:47 +0530 Subject: [PATCH 3/6] refactor(config): parametrize hardcoded instanode.dev URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new ServerConfig fields + a paths.go constants file so self-hosters can point the binary at their own marketing site and cookie domain without patching Go source. ServerConfig additions: - MarketingURL: public website host for post-OAuth redirects, upgrade links, email CTAs. Empty = marketing redirects 404. - CookieDomain: session cookie Domain attribute. Empty = scoped to API host (right default for single-host + local dev). - AllowedOrigins: exact-match CORS allowlist, iterated instead of hardcoded switch. Replaced call sites use these plus new route-path constants (pathMarketingDashboard, pathMarketingPricing, pathAPIWebhookReceive etc.) so URL fragments aren't scattered as string literals. Also: - Email FromAddress default: no-reply@instanode.dev → no-reply@example.com (config-driven already; neutralising the baked-in default) - User-visible "contact contact@instanode.dev" error strings → "contact support" (brand-neutral) - handlers.go: removed the instanode.dev→api.instanode.dev receive-URL swap (redundant once BaseURL is configured correctly) - email.go: TODO comment flagging the remaining URL/brand hardcoding in HTML templates — deferred to a proper templating abstraction. go build / go vet / go test all pass. Only remaining instanode.dev references are in email templates (TODO'd) and test fixtures. --- internal/server/auth.go | 34 +++++++---- internal/server/billing_change_plan.go | 4 +- internal/server/billing_subscriptions.go | 2 +- internal/server/config.go | 28 ++++++++- internal/server/config_test.go | 4 +- internal/server/dashboard.go | 8 +-- internal/server/email.go | 7 +++ internal/server/handlers.go | 13 +---- internal/server/inbound.go | 2 +- internal/server/main.go | 73 +++++++++++++++--------- internal/server/paths.go | 22 +++++++ internal/server/plan_test.go | 10 ++-- 12 files changed, 143 insertions(+), 64 deletions(-) create mode 100644 internal/server/paths.go diff --git a/internal/server/auth.go b/internal/server/auth.go index 81e647b..cd71ef9 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -25,7 +25,11 @@ func (s *server) oauthFail(w http.ResponseWriter, r *http.Request, code string, } else { slog.WarnContext(r.Context(), "oauth: "+code) } - http.Redirect(w, r, "https://instanode.dev/start?error="+url.QueryEscape(code), http.StatusFound) + if s.marketingURL == "" { + writeError(w, http.StatusBadRequest, "oauth_"+code, "OAuth flow failed.") + return + } + http.Redirect(w, r, s.marketingURL+pathMarketingStartErrorQS+url.QueryEscape(code), http.StatusFound) } type User struct { @@ -296,23 +300,29 @@ func (s *server) handleGitHubCallback(w http.ResponseWriter, r *http.Request) { s.email.SendAsync(githubUser.Email, subject, html) } - // Session cookie. Shared across api.instanode.dev and instanode.dev so - // the static marketing/dashboard pages can authenticate fetch() calls - // against the API. SameSite=None + Secure are required by modern - // browsers for cross-site cookie sends. + // Session cookie. When CookieDomain is set (e.g. "example.com"), the + // cookie is shared across api.example.com and example.com so static + // marketing pages can authenticate fetch() calls against the API. Leave + // empty to scope to the API host only (the right default for single-host + // and local-dev deployments). SameSite=None + Secure are required by + // modern browsers for cross-site cookie sends. http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", - Domain: "instanode.dev", + Domain: s.cfg.Server.CookieDomain, HttpOnly: true, Secure: true, SameSite: http.SameSiteNoneMode, MaxAge: int(jwtTTL.Seconds()), }) - // After login, drop the user on the dashboard on the marketing domain. - http.Redirect(w, r, "https://instanode.dev/dashboard.html", http.StatusFound) + // After login, drop the user on the dashboard on the marketing site. + dashURL := pathMarketingDashboardPage + if s.marketingURL != "" { + dashURL = s.marketingURL + pathMarketingDashboardPage + } + http.Redirect(w, r, dashURL, http.StatusFound) } func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { @@ -320,13 +330,17 @@ func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { Name: "session", Value: "", Path: "/", - Domain: "instanode.dev", + Domain: s.cfg.Server.CookieDomain, HttpOnly: true, Secure: true, SameSite: http.SameSiteNoneMode, MaxAge: -1, }) - http.Redirect(w, r, "https://instanode.dev/", http.StatusFound) + home := pathMarketingHome + if s.marketingURL != "" { + home = s.marketingURL + pathMarketingHome + } + http.Redirect(w, r, home, http.StatusFound) } func (s *server) handleMe(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/billing_change_plan.go b/internal/server/billing_change_plan.go index 525deec..3f74789 100644 --- a/internal/server/billing_change_plan.go +++ b/internal/server/billing_change_plan.go @@ -292,7 +292,7 @@ func (s *server) handleCancelPlanChange(w http.ResponseWriter, r *http.Request) return case cancelSwitchAlreadyFired: writeError(w, http.StatusConflict, "switch_already_fired", - "The switch has already been initiated at the billing provider — email contact@instanode.dev if you need to reverse it.") + "The switch has already been initiated at the billing provider — contact support if you need to reverse it.") return } @@ -316,7 +316,7 @@ func (s *server) handleCancelPlanChange(w http.ResponseWriter, r *http.Request) if err == sql.ErrNoRows { // Race: someone else cleared it (e.g. reconciler just fired). writeError(w, http.StatusConflict, "switch_already_fired", - "The switch has just been initiated — email contact@instanode.dev to reverse it.") + "The switch has just been initiated — contact support to reverse it.") return } if err != nil { diff --git a/internal/server/billing_subscriptions.go b/internal/server/billing_subscriptions.go index 51c558f..25630f9 100644 --- a/internal/server/billing_subscriptions.go +++ b/internal/server/billing_subscriptions.go @@ -120,7 +120,7 @@ func (s *server) handleCreateSubscription(w http.ResponseWriter, r *http.Request slog.ErrorContext(r.Context(), "razorpay subscription create failed", "error", res.err, "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) writeError(w, http.StatusBadGateway, "payment_gateway_error", - "Payment provider returned an error — please try again in a moment. If the problem persists, email contact@instanode.dev.") + "Payment provider returned an error — please try again in a moment. If the problem persists, contact support.") return } slog.InfoContext(r.Context(), "razorpay subscription create: ok", diff --git a/internal/server/config.go b/internal/server/config.go index 7ca31c5..fe30abf 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -87,6 +87,24 @@ type ServerConfig struct { ReadTimeout string `yaml:"read_timeout"` WriteTimeout string `yaml:"write_timeout"` IdleTimeout string `yaml:"idle_timeout"` + + // MarketingURL is the public website host (landing, /pricing, /dashboard). + // Used for post-OAuth redirects, upgrade links, email CTAs. Leave empty to + // disable marketing redirects entirely (the API will 404 those paths). + MarketingURL string `yaml:"marketing_url"` + + // CookieDomain is the Domain attribute on the session cookie. Set to the + // bare registrable domain (e.g. "example.com") to share the session + // across api.example.com and example.com. Leave empty to scope the + // cookie to the API host only — the right default for single-host + // deployments and local development. + CookieDomain string `yaml:"cookie_domain"` + + // AllowedOrigins is the exact-match allowlist echoed into + // Access-Control-Allow-Origin for browser clients. Non-matching + // browser origins fall back to a wildcard with no credentials. Non- + // browser callers (curl, SDK) are unaffected either way. + AllowedOrigins []string `yaml:"allowed_origins"` } type DatabaseConfig struct { @@ -178,6 +196,12 @@ func DefaultConfig() *Config { ReadTimeout: "10s", WriteTimeout: "30s", IdleTimeout: "60s", + MarketingURL: "http://localhost:5173", + CookieDomain: "", + AllowedOrigins: []string{ + "http://localhost:5173", + "http://localhost:3000", + }, }, Database: DatabaseConfig{ PlatformURL: "postgres://instant:instant@localhost:5432/instant_lite?sslmode=disable", @@ -213,8 +237,8 @@ func DefaultConfig() *Config { }, Email: EmailConfig{ SMTPPort: 587, - FromAddress: "no-reply@instanode.dev", - FromName: "instanode", + FromAddress: "no-reply@example.com", + FromName: "instant-lite", }, Observability: ObservabilityConfig{ Enabled: false, diff --git a/internal/server/config_test.go b/internal/server/config_test.go index 024e32c..b3e4534 100644 --- a/internal/server/config_test.go +++ b/internal/server/config_test.go @@ -45,8 +45,8 @@ func TestDefaultConfig(t *testing.T) { if cfg.Email.SMTPPort != 587 { t.Errorf("Email.SMTPPort = %d, want 587", cfg.Email.SMTPPort) } - if cfg.Email.FromAddress != "no-reply@instanode.dev" { - t.Errorf("Email.FromAddress = %q, want %q", cfg.Email.FromAddress, "no-reply@instanode.dev") + if cfg.Email.FromAddress != "no-reply@example.com" { + t.Errorf("Email.FromAddress = %q, want %q", cfg.Email.FromAddress, "no-reply@example.com") } } diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index b87f277..69706a7 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -77,7 +77,7 @@ func (s *server) handleGetPlan(w http.ResponseWriter, r *http.Request) { } humanLabel := buildHumanPlanLabel(user) - upgrades := buildAvailableUpgrades(user) + upgrades := buildAvailableUpgrades(s.baseURL, user) payload := map[string]any{ "plan_tier": user.PlanTier, @@ -165,7 +165,7 @@ func (s *server) handleDeleteResource(w http.ResponseWriter, r *http.Request) { "ok": false, "error": "paid_tier_only", "message": "Delete is a Developer-tier feature. Free-tier resources auto-expire in 24 hours — upgrade to remove them on demand.", - "upgrade_url": "https://instanode.dev/pricing.html?token=" + tokenUUID.String(), + "upgrade_url": s.marketingURL + pathMarketingPricingPage + "?token=" + tokenUUID.String(), }) return } @@ -447,7 +447,7 @@ func planPriceLabels(currency string) (monthly, annual string) { // self-describing instruction (method/url/body/auth) so the agent can // subscribe without scraping docs. Free → monthly + annual; paid monthly → // annual; paid annual → none (cancellation-only). -func buildAvailableUpgrades(user *User) []map[string]any { +func buildAvailableUpgrades(baseURL string, user *User) []map[string]any { upgrades := []map[string]any{} instruction := func(plan, label string, price int, interval string) map[string]any { return map[string]any{ @@ -457,7 +457,7 @@ func buildAvailableUpgrades(user *User) []map[string]any { "billing_interval": interval, "how_to_subscribe": map[string]any{ "method": "POST", - "url": "https://api.instanode.dev/billing/create-subscription", + "url": baseURL + pathAPIBillingSubscription, "headers": map[string]string{"Authorization": "Bearer ", "Content-Type": "application/json"}, "body": map[string]string{"plan": plan}, "response_field": "short_url", diff --git a/internal/server/email.go b/internal/server/email.go index 5f27ded..27452f1 100644 --- a/internal/server/email.go +++ b/internal/server/email.go @@ -62,6 +62,13 @@ func (e *emailer) send(ctx context.Context, to, subject, htmlBody string) error } // ── Templates (inline HTML — deliberately minimal; swap for Brevo template IDs if volume grows). ── +// +// TODO(self-hoster): these templates hard-code URLs (instanode.dev) and a +// support email (contact@instanode.dev). Customize them for your deployment — +// either replace the strings inline or move to a templating layer that reads +// server.MarketingURL and a new SupportAddress config field. Left as-is on +// the OSS branch so the code doesn't diverge from the reference deployment +// before a proper templating abstraction lands. func welcomeEmail() (subject, html string) { subject = "Welcome to instanode" diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 4f0bc9f..629ce20 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -227,16 +227,9 @@ func (s *server) handleNewWebhook(w http.ResponseWriter, r *http.Request) { t := time.Now().UTC().Add(anonTTL) expiresAt = &t } - // baseURL is the marketing host (https://instanode.dev) which serves - // static pages on GitHub Pages — POST requests to it return 405. Emit - // webhook receive URLs against the API host instead. - receiveBase := s.cfg.Server.BaseURL - if strings.HasPrefix(receiveBase, "https://instanode.dev") { - receiveBase = "https://api.instanode.dev" - } else if strings.HasPrefix(receiveBase, "http://instanode.dev") { - receiveBase = "http://api.instanode.dev" - } - receiveURL := fmt.Sprintf("%s/webhook/receive/%s", receiveBase, token.String()) + // receive URLs always target the API host (this binary). BaseURL is the + // API host by contract — configure it to the public API URL in production. + receiveURL := strings.TrimRight(s.baseURL, "/") + pathAPIWebhookReceive + token.String() id := uuid.New() var err error diff --git a/internal/server/inbound.go b/internal/server/inbound.go index e54e4bc..edd3109 100644 --- a/internal/server/inbound.go +++ b/internal/server/inbound.go @@ -37,7 +37,7 @@ import ( // maxInboundBodyBytes caps the POST body. Brevo inbound payloads can be // surprisingly large (HTML bodies + inlined attachments in base64), but 10 MB -// is well above any realistic email we'd expect at contact@instanode.dev. +// is well above any realistic inbound email we'd expect. const maxInboundBodyBytes = 10 * 1024 * 1024 // inboundMessage is the flattened, DB-ready representation of one parsed diff --git a/internal/server/main.go b/internal/server/main.go index 2679fbf..226e13e 100644 --- a/internal/server/main.go +++ b/internal/server/main.go @@ -27,13 +27,14 @@ var llmsTxt []byte var schemaSQL string type server struct { - db *sql.DB - rdb *redis.Client // Valkey (rate limits, webhook storage, and where - // per-tenant ACL users are provisioned) - cfg *Config - baseURL string - custDBURL string // customer Postgres (where we CREATE DATABASE) - email *emailer + db *sql.DB + rdb *redis.Client // Valkey (rate limits, webhook storage, and where + // per-tenant ACL users are provisioned) + cfg *Config + baseURL string // API host, e.g. https://api.example.com — served by this binary + marketingURL string // public website host, e.g. https://example.com — served elsewhere + custDBURL string // customer Postgres (where we CREATE DATABASE) + email *emailer } // Run boots the instant-lite server: loads config, initializes @@ -101,12 +102,13 @@ func Run() { } s := &server{ - db: db, - rdb: rdb, - cfg: cfg, - baseURL: cfg.Server.BaseURL, - custDBURL: cfg.Database.CustomerURL, - email: newEmailer(cfg.Email), + db: db, + rdb: rdb, + cfg: cfg, + baseURL: cfg.Server.BaseURL, + marketingURL: cfg.Server.MarketingURL, + custDBURL: cfg.Database.CustomerURL, + email: newEmailer(cfg.Email), } // Start the expired resource reaper. @@ -175,10 +177,18 @@ func Run() { mux.HandleFunc("GET /api/me/plan", s.handleGetPlan) mux.HandleFunc("DELETE /api/me/resources/{token}", s.handleDeleteResource) mux.HandleFunc("GET /dashboard", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://instanode.dev/dashboard", http.StatusFound) + if s.marketingURL == "" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, s.marketingURL+pathMarketingDashboard, http.StatusFound) }) mux.HandleFunc("GET /pricing", func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "https://instanode.dev/pricing", http.StatusFound) + if s.marketingURL == "" { + http.NotFound(w, r) + return + } + http.Redirect(w, r, s.marketingURL+pathMarketingPricing, http.StatusFound) }) // Health @@ -222,15 +232,20 @@ func Run() { }) mux.HandleFunc("GET /openapi.json", s.handleOpenAPI) - // Root — redirect to website (hosted separately on GitHub Pages). + // Root — redirect to marketing site (hosted elsewhere). When MarketingURL + // is empty (self-hoster didn't configure one), fall through to 404 so the + // binary stays usable without a website alongside it. mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { + if s.marketingURL == "" { + http.NotFound(w, r) + return + } if r.URL.Path == "/" { - http.Redirect(w, r, "https://instanode.dev", http.StatusFound) + http.Redirect(w, r, s.marketingURL, http.StatusFound) return } - if r.URL.Path == "/start" { - // Serve the start page or redirect to frontend - http.Redirect(w, r, "https://instanode.dev/start" + r.URL.RawQuery, http.StatusFound) + if r.URL.Path == pathMarketingStart { + http.Redirect(w, r, s.marketingURL+pathMarketingStart+r.URL.RawQuery, http.StatusFound) return } http.NotFound(w, r) @@ -281,16 +296,20 @@ func withMiddleware(next http.Handler, cfg *Config) http.Handler { w.Header().Set("X-Request-ID", fmt.Sprintf("%d", time.Now().UnixNano())) // Browsers reject Access-Control-Allow-Credentials: true together with - // a wildcard origin. Echo the request Origin when it's one of ours, + // a wildcard origin. Echo the request Origin when it's one we allow, // otherwise fall back to wildcard for non-browser (curl/SDK) clients. origin := r.Header.Get("Origin") - switch origin { - case "https://instanode.dev", "http://localhost:5173", "http://localhost:3000": - w.Header().Set("Access-Control-Allow-Origin", origin) - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Vary", "Origin") - case "": + if origin == "" { w.Header().Set("Access-Control-Allow-Origin", "*") + } else { + for _, allowed := range cfg.Server.AllowedOrigins { + if origin == allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Vary", "Origin") + break + } + } } w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Requested-With") diff --git a/internal/server/paths.go b/internal/server/paths.go new file mode 100644 index 0000000..c45dfd6 --- /dev/null +++ b/internal/server/paths.go @@ -0,0 +1,22 @@ +package server + +// Route path fragments. Centralised so a rename is one edit and reviewers can +// see the full URL vocabulary at a glance. Used by handlers that emit links +// back to the marketing site or the API itself. + +const ( + // Marketing site paths (rendered by the static website, not this binary). + // Concatenated with server.Config.MarketingURL at call sites. + pathMarketingHome = "/" + pathMarketingDashboard = "/dashboard" + pathMarketingDashboardPage = "/dashboard.html" + pathMarketingPricing = "/pricing" + pathMarketingPricingPage = "/pricing.html" + pathMarketingStart = "/start" + pathMarketingStartErrorQS = "/start?error=" + + // API paths served by this binary. Concatenated with server.baseURL when + // surfaced back to clients (e.g. dashboard JSON, emitted webhook URLs). + pathAPIBillingSubscription = "/billing/create-subscription" + pathAPIWebhookReceive = "/webhook/receive/" +) diff --git a/internal/server/plan_test.go b/internal/server/plan_test.go index 960b2f1..d67e1cd 100644 --- a/internal/server/plan_test.go +++ b/internal/server/plan_test.go @@ -334,7 +334,7 @@ func TestBuildHumanPlanLabel_Halted(t *testing.T) { func TestBuildAvailableUpgrades_FreeSeeesBothPaths(t *testing.T) { u := &User{PlanTier: "free"} - out := buildAvailableUpgrades(u) + out := buildAvailableUpgrades("https://api.example.test", u) if len(out) != 2 { t.Fatalf("free tier should see 2 upgrades, got %d", len(out)) } @@ -345,7 +345,7 @@ func TestBuildAvailableUpgrades_FreeSeeesBothPaths(t *testing.T) { } func TestBuildAvailableUpgrades_NilUserDefaultsToFree(t *testing.T) { - out := buildAvailableUpgrades(nil) + out := buildAvailableUpgrades("https://api.example.test", nil) if len(out) != 2 { t.Errorf("nil user should still see 2 upgrade paths (free behaviour), got %d", len(out)) } @@ -353,7 +353,7 @@ func TestBuildAvailableUpgrades_NilUserDefaultsToFree(t *testing.T) { func TestBuildAvailableUpgrades_PaidMonthlySeesAnnual(t *testing.T) { u := &User{PlanTier: "paid", PlanPeriod: "monthly"} - out := buildAvailableUpgrades(u) + out := buildAvailableUpgrades("https://api.example.test", u) if len(out) != 1 { t.Fatalf("paid monthly should see 1 upgrade (annual), got %d", len(out)) } @@ -364,7 +364,7 @@ func TestBuildAvailableUpgrades_PaidMonthlySeesAnnual(t *testing.T) { func TestBuildAvailableUpgrades_PaidAnnualSeesNothing(t *testing.T) { u := &User{PlanTier: "paid", PlanPeriod: "annual"} - out := buildAvailableUpgrades(u) + out := buildAvailableUpgrades("https://api.example.test", u) if len(out) != 0 { t.Errorf("paid annual should see 0 upgrades, got %d: %v", len(out), out) } @@ -372,7 +372,7 @@ func TestBuildAvailableUpgrades_PaidAnnualSeesNothing(t *testing.T) { func TestBuildAvailableUpgrades_InstructionShape(t *testing.T) { u := &User{PlanTier: "free"} - out := buildAvailableUpgrades(u) + out := buildAvailableUpgrades("https://api.example.test", u) if len(out) == 0 { t.Fatal("need at least one upgrade for this test") } From cfb7c9e466fe3fd9aedbe994836615193d0d38b7 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 23 Apr 2026 22:17:02 +0530 Subject: [PATCH 4/6] refactor: extract Payment interface for swappable billing provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a Payment interface (internal/server/payment.go) with two shipping implementations: - razorpayPayment (razorpay_client.go): wraps razorpay-go, runs each SDK call in a goroutine and selects on ctx.Done so the 15s timeouts the handlers pass in actually land on the wire. - noopPayment (payment_noop.go): returns ErrPaymentNotConfigured from every call so self-hosters can run the API (provisioning, auth, webhooks) without signing up for Razorpay. main.Run picks between them based on RAZORPAY_KEY_ID + RAZORPAY_KEY_SECRET being set. Endpoints that require billing now check s.payment.Configured() up-front and return 503 when the noop is installed. Handler callsites replaced: - billing_orders.go: newRazorpayClient → s.payment.CreateOrder - billing_webhook.go: signature check → s.payment.VerifyWebhookSignature; 3× Order.Fetch → s.payment.FetchOrder - billing_subscriptions.go: goroutine+select pattern → s.payment.CreateSubscription (pattern now lives inside the impl) - main.go: /debug/razorpay-ping → /debug/payment-ping using s.payment.ListSubscriptions Kept for backwards compat: - liveRazorpayCreateSub / liveRazorpayCancelSub (used by the reconciler as function values) continue to call newRazorpayClient directly. Tests in billing_razorpay_sdk_test.go still pass unchanged. - s.computeSignature method retained for unit tests that sign fixture webhook bodies; production code uses VerifyWebhookSignature. go build / go vet / go test all pass. --- internal/server/billing_orders.go | 26 ++++--- internal/server/billing_subscriptions.go | 64 ++++++++--------- internal/server/billing_webhook.go | 15 ++-- internal/server/billing_webhook_test.go | 11 +-- internal/server/main.go | 35 ++++++---- internal/server/payment.go | 45 ++++++++++++ internal/server/payment_noop.go | 33 +++++++++ internal/server/razorpay_client.go | 88 ++++++++++++++++++++++++ 8 files changed, 244 insertions(+), 73 deletions(-) create mode 100644 internal/server/payment.go create mode 100644 internal/server/payment_noop.go diff --git a/internal/server/billing_orders.go b/internal/server/billing_orders.go index fe3c9fa..8916666 100644 --- a/internal/server/billing_orders.go +++ b/internal/server/billing_orders.go @@ -1,9 +1,11 @@ package server import ( + "context" "encoding/json" "log/slog" "net/http" + "time" "github.com/google/uuid" ) @@ -44,9 +46,13 @@ var planPricing = map[string]map[string]int{ func (s *server) handleCreateOrder(w http.ResponseWriter, r *http.Request) { // Note: no direct platform-PG calls here; authUser handles its own 5s - // timeout internally. The only external call is client.Order.Create - // below, which the Razorpay Go SDK runs without context support — see - // comment at that call site. + // timeout internally. The only external call is s.payment.CreateOrder + // below, which runs the SDK call in a goroutine so ctx cancellation + // can unblock us even when the underlying SDK blocks indefinitely. + if !s.payment.Configured() { + writeError(w, http.StatusServiceUnavailable, "payment_not_configured", "Billing is not configured on this deployment.") + return + } user := s.authUser(r) if user == nil { writeError(w, http.StatusUnauthorized, "unauthorized", "Sign in required.") @@ -74,8 +80,6 @@ func (s *server) handleCreateOrder(w http.ResponseWriter, r *http.Request) { return } - client := newRazorpayClient(s.cfg.Razorpay) - notes := map[string]interface{}{ "user_id": user.ID.String(), "plan_id": req.PlanID, @@ -94,12 +98,12 @@ func (s *server) handleCreateOrder(w http.ResponseWriter, r *http.Request) { "notes": notes, } - // LIMITATION: the Razorpay Go SDK does not accept a context.Context here, - // so we cannot enforce our 5s request budget on this call. It will stall - // up to Razorpay's own SDK-internal HTTP timeout (currently unbounded in - // razorpay-go). If this becomes a production hang risk, wrap with a - // channel + time.After pattern and abandon the goroutine on timeout. - order, err := client.Order.Create(data, nil) + // Bound the provider call to 15s — beyond that the user's browser has + // given up anyway. The Payment impl runs the SDK in a goroutine and + // selects on ctx.Done so this timeout actually lands on the wire. + orderCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + order, err := s.payment.CreateOrder(orderCtx, data) if err != nil { slog.ErrorContext(r.Context(), "razorpay order create failed", "error", err, "user_id", user.ID, "plan", req.PlanID, "currency", req.Currency) writeError(w, http.StatusBadGateway, "payment_gateway_error", "Payment provider is unavailable — please try again in a moment.") diff --git a/internal/server/billing_subscriptions.go b/internal/server/billing_subscriptions.go index 25630f9..9ee7b63 100644 --- a/internal/server/billing_subscriptions.go +++ b/internal/server/billing_subscriptions.go @@ -89,50 +89,40 @@ func (s *server) handleCreateSubscription(w http.ResponseWriter, r *http.Request } } - // Razorpay SDK call with timing checkpoints so we can distinguish - // "SDK never returned" vs "Razorpay responded slowly" vs "outbound - // blocked at the container level" in production logs. + // Payment provider call with timing checkpoints so we can distinguish + // "SDK never returned" vs "provider responded slowly" vs "outbound + // blocked at the container level" in production logs. The Payment + // impl runs the SDK in a goroutine and selects on ctx.Done so the + // 15s bound below actually lands on the wire. callStart := time.Now() - slog.InfoContext(r.Context(), "razorpay subscription create: starting", + slog.InfoContext(r.Context(), "payment subscription create: starting", "user_id", user.ID, "plan", req.Plan, "plan_id", planID) - type subResult struct { - data map[string]interface{} - err error - } - resCh := make(chan subResult, 1) - go func() { - client := newRazorpayClient(s.cfg.Razorpay) - data, err := client.Subscription.Create(map[string]interface{}{ - "plan_id": planID, - "total_count": totalCount, - "customer_notify": 1, - "notes": notes, - }, nil) - resCh <- subResult{data: data, err: err} - }() - - var sub map[string]interface{} - select { - case res := <-resCh: - elapsed := time.Since(callStart) - if res.err != nil { - slog.ErrorContext(r.Context(), "razorpay subscription create failed", - "error", res.err, "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) - writeError(w, http.StatusBadGateway, "payment_gateway_error", - "Payment provider returned an error — please try again in a moment. If the problem persists, contact support.") + subCtx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + sub, err := s.payment.CreateSubscription(subCtx, map[string]interface{}{ + "plan_id": planID, + "total_count": totalCount, + "customer_notify": 1, + "notes": notes, + }) + elapsed := time.Since(callStart) + if err != nil { + if subCtx.Err() == context.DeadlineExceeded { + slog.ErrorContext(r.Context(), "payment subscription create timeout", + "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) + writeError(w, http.StatusGatewayTimeout, "payment_gateway_timeout", + "Payment provider took too long to respond. Please retry in a few seconds.") return } - slog.InfoContext(r.Context(), "razorpay subscription create: ok", - "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) - sub = res.data - case <-time.After(15 * time.Second): - slog.ErrorContext(r.Context(), "razorpay subscription create timeout", - "user_id", user.ID, "plan", req.Plan, "elapsed_ms", time.Since(callStart).Milliseconds()) - writeError(w, http.StatusGatewayTimeout, "payment_gateway_timeout", - "Payment provider took too long to respond. Please retry in a few seconds.") + slog.ErrorContext(r.Context(), "payment subscription create failed", + "error", err, "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) + writeError(w, http.StatusBadGateway, "payment_gateway_error", + "Payment provider returned an error — please try again in a moment. If the problem persists, contact support.") return } + slog.InfoContext(r.Context(), "payment subscription create: ok", + "user_id", user.ID, "plan", req.Plan, "elapsed_ms", elapsed.Milliseconds()) subID, _ := sub["id"].(string) shortURL, _ := sub["short_url"].(string) diff --git a/internal/server/billing_webhook.go b/internal/server/billing_webhook.go index edd0880..099a298 100644 --- a/internal/server/billing_webhook.go +++ b/internal/server/billing_webhook.go @@ -29,8 +29,7 @@ func (s *server) handleRazorpayWebhook(w http.ResponseWriter, r *http.Request) { } signature := r.Header.Get("X-Razorpay-Signature") - expectedSignature := s.computeSignature(string(body), s.cfg.Razorpay.WebhookSecret) - if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { + if !s.payment.VerifyWebhookSignature(body, signature) { slog.WarnContext(r.Context(), "razorpay webhook: signature mismatch") writeError(w, http.StatusUnauthorized, "invalid_signature", "Signature verification failed.") return @@ -171,8 +170,7 @@ func (s *server) handlePaymentCaptured(ctx context.Context, entity map[string]in slog.Error("payment.captured missing order_id and unresolvable subscription_id", "payment_id", paymentID) return } - client := newRazorpayClient(s.cfg.Razorpay) - order, err := client.Order.Fetch(orderID, nil, nil) + order, err := s.payment.FetchOrder(ctx, orderID) if err != nil { slog.Error("razorpay order fetch failed", "error", err, "order_id", orderID, "payment_id", paymentID) return @@ -256,8 +254,7 @@ func (s *server) handlePaymentCaptured(ctx context.Context, entity map[string]in // fallback path. (Kept this comment so future readers don't move it.) } // handleLegacyNotesTokenClaim is inlined so we keep notes in scope: - client := newRazorpayClient(s.cfg.Razorpay) - order, err := client.Order.Fetch(orderID, nil, nil) + order, err := s.payment.FetchOrder(ctx, orderID) if err == nil { if n, ok := order["notes"].(map[string]interface{}); ok { if tokenStr, _ := n["token"].(string); tokenStr != "" { @@ -303,6 +300,9 @@ func (s *server) handlePaymentCaptured(ctx context.Context, entity map[string]in sendReceiptIfUnsent(ctx, s.db, s.email, userID, amountCents, currency) } +// computeSignature is retained for tests that sign fixture webhook bodies +// to exercise the verification path. Production code calls +// s.payment.VerifyWebhookSignature — don't reach for this in handlers. func (s *server) computeSignature(payload, secret string) string { h := hmac.New(sha256.New, []byte(secret)) h.Write([]byte(payload)) @@ -316,8 +316,7 @@ func (s *server) notifyPaymentFailed(ctx context.Context, orderID, reason string if orderID == "" { return } - client := newRazorpayClient(s.cfg.Razorpay) - order, err := client.Order.Fetch(orderID, nil, nil) + order, err := s.payment.FetchOrder(ctx, orderID) if err != nil { slog.Warn("payment_failed email: order fetch failed", "error", err, "order_id", orderID) return diff --git a/internal/server/billing_webhook_test.go b/internal/server/billing_webhook_test.go index 73dab2f..68e99ba 100644 --- a/internal/server/billing_webhook_test.go +++ b/internal/server/billing_webhook_test.go @@ -20,12 +20,15 @@ import ( // regression in the signature logic before any deploy. // whsrv builds a minimal *server with just enough config for the webhook -// handler to run — no DB, no email, no redis. +// handler to run — no DB, no email, no redis. A real razorpayPayment is +// injected so signature verification runs against the test secret. func whsrv(secret string) *server { + cfg := &Config{ + Razorpay: RazorpayConfig{WebhookSecret: secret}, + } return &server{ - cfg: &Config{ - Razorpay: RazorpayConfig{WebhookSecret: secret}, - }, + cfg: cfg, + payment: newRazorpayPayment(cfg.Razorpay), } } diff --git a/internal/server/main.go b/internal/server/main.go index 226e13e..1f3c78f 100644 --- a/internal/server/main.go +++ b/internal/server/main.go @@ -31,10 +31,11 @@ type server struct { rdb *redis.Client // Valkey (rate limits, webhook storage, and where // per-tenant ACL users are provisioned) cfg *Config - baseURL string // API host, e.g. https://api.example.com — served by this binary - marketingURL string // public website host, e.g. https://example.com — served elsewhere - custDBURL string // customer Postgres (where we CREATE DATABASE) + baseURL string // API host, e.g. https://api.example.com — served by this binary + marketingURL string // public website host, e.g. https://example.com — served elsewhere + custDBURL string // customer Postgres (where we CREATE DATABASE) email *emailer + payment Payment // billing provider (Razorpay in prod, no-op when unconfigured) } // Run boots the instant-lite server: loads config, initializes @@ -101,6 +102,15 @@ func Run() { slog.Warn("redis unreachable — rate limiting and webhooks will be degraded", "error", err) } + var payment Payment + if cfg.Razorpay.KeyID != "" && cfg.Razorpay.KeySecret != "" { + payment = newRazorpayPayment(cfg.Razorpay) + slog.Info("payment: razorpay provider active") + } else { + payment = noopPayment{} + slog.Warn("payment: provider not configured, billing endpoints will return 503") + } + s := &server{ db: db, rdb: rdb, @@ -109,6 +119,7 @@ func Run() { marketingURL: cfg.Server.MarketingURL, custDBURL: cfg.Database.CustomerURL, email: newEmailer(cfg.Email), + payment: payment, } // Start the expired resource reaper. @@ -196,22 +207,20 @@ func Run() { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "service": "instant-lite"}) }) - // Temporary egress diagnostic: GET /debug/razorpay-ping returns how long - // Razorpay's /v1/subscriptions takes from inside the container. Gate: - // requires ?token= so only the operator can hit it. - mux.HandleFunc("GET /debug/razorpay-ping", func(w http.ResponseWriter, r *http.Request) { - // Gate on the Brevo inbound secret (already SECRET in DO env, known - // to operator) so this diagnostic endpoint can be hit from anywhere. + // Temporary egress diagnostic: GET /debug/payment-ping returns how long + // a list-subscriptions call takes from inside the container, useful for + // diagnosing container-level network stalls. Gated on the Brevo inbound + // secret (already SECRET in env, known to the operator). + mux.HandleFunc("GET /debug/payment-ping", func(w http.ResponseWriter, r *http.Request) { expected := cfg.Email.BrevoInboundSecret if expected == "" || r.URL.Query().Get("token") != expected { writeError(w, http.StatusUnauthorized, "unauthorized", "debug endpoint — token required") return } start := time.Now() - client := newRazorpayClient(cfg.Razorpay) - // Call the simplest GET — /v1/subscriptions — with a small page to - // time just the HTTP + auth round-trip. - _, err := client.Subscription.All(map[string]interface{}{"count": 1}, nil) + pingCtx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + _, err := s.payment.ListSubscriptions(pingCtx, map[string]interface{}{"count": 1}) elapsed := time.Since(start) if err != nil { writeJSON(w, http.StatusOK, map[string]any{"ok": false, "elapsed_ms": elapsed.Milliseconds(), "error": err.Error()}) diff --git a/internal/server/payment.go b/internal/server/payment.go new file mode 100644 index 0000000..03ca4a0 --- /dev/null +++ b/internal/server/payment.go @@ -0,0 +1,45 @@ +package server + +import ( + "context" + "errors" +) + +// ErrPaymentNotConfigured is returned by every Payment method on a noopPayment. +// Handlers that require billing should map it to 503 Service Unavailable — the +// operator hasn't wired up a billing provider for this deployment. +var ErrPaymentNotConfigured = errors.New("payment: provider not configured") + +// Payment abstracts the billing provider. Two implementations ship today: +// +// - razorpayPayment (internal/server/razorpay_client.go) — real Razorpay +// calls; selected by main.Run when RAZORPAY_KEY_ID + RAZORPAY_KEY_SECRET +// are both set. +// - noopPayment (internal/server/payment_noop.go) — returns +// ErrPaymentNotConfigured from every call; selected as the default so a +// self-hoster can run the rest of the API (provisioning, auth, webhooks) +// without signing up for Razorpay. +// +// Method payloads are map[string]interface{} today because that's the shape +// the razorpay-go SDK uses. As more providers land, replace with typed +// structs — razorpayPayment can marshal between the two. +type Payment interface { + // Configured reports whether the backend has enough config + // (credentials, plan IDs) to service live requests. Handlers should + // short-circuit to 503 when this is false instead of calling through. + Configured() bool + + // Orders — legacy one-time-charge flow (POST /billing/create-order). + CreateOrder(ctx context.Context, data map[string]interface{}) (map[string]interface{}, error) + FetchOrder(ctx context.Context, orderID string) (map[string]interface{}, error) + + // Subscriptions — recurring billing (preferred flow). + CreateSubscription(ctx context.Context, data map[string]interface{}) (map[string]interface{}, error) + CancelSubscription(ctx context.Context, subID string, opts map[string]interface{}) (map[string]interface{}, error) + ListSubscriptions(ctx context.Context, opts map[string]interface{}) (map[string]interface{}, error) + + // VerifyWebhookSignature returns true iff signature is a valid + // provider-specific MAC of body. Boolean rather than error so + // handlers don't branch on error types for the common auth path. + VerifyWebhookSignature(body []byte, signature string) bool +} diff --git a/internal/server/payment_noop.go b/internal/server/payment_noop.go new file mode 100644 index 0000000..3d63f39 --- /dev/null +++ b/internal/server/payment_noop.go @@ -0,0 +1,33 @@ +package server + +import "context" + +// noopPayment is the default Payment when no billing provider is configured. +// Every method returns ErrPaymentNotConfigured so handlers can uniformly map +// that to 503. Webhook signature checks always fail — unsigned callers can't +// drive state changes on a deployment without credentials. +type noopPayment struct{} + +func (noopPayment) Configured() bool { return false } + +func (noopPayment) CreateOrder(ctx context.Context, data map[string]interface{}) (map[string]interface{}, error) { + return nil, ErrPaymentNotConfigured +} + +func (noopPayment) FetchOrder(ctx context.Context, orderID string) (map[string]interface{}, error) { + return nil, ErrPaymentNotConfigured +} + +func (noopPayment) CreateSubscription(ctx context.Context, data map[string]interface{}) (map[string]interface{}, error) { + return nil, ErrPaymentNotConfigured +} + +func (noopPayment) CancelSubscription(ctx context.Context, subID string, opts map[string]interface{}) (map[string]interface{}, error) { + return nil, ErrPaymentNotConfigured +} + +func (noopPayment) ListSubscriptions(ctx context.Context, opts map[string]interface{}) (map[string]interface{}, error) { + return nil, ErrPaymentNotConfigured +} + +func (noopPayment) VerifyWebhookSignature(body []byte, signature string) bool { return false } diff --git a/internal/server/razorpay_client.go b/internal/server/razorpay_client.go index 2148552..36a2d3d 100644 --- a/internal/server/razorpay_client.go +++ b/internal/server/razorpay_client.go @@ -1,6 +1,10 @@ package server import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "sync/atomic" "github.com/razorpay/razorpay-go" @@ -43,3 +47,87 @@ func newRazorpayClient(cfg RazorpayConfig) *razorpay.Client { } return c } + +// ── Payment implementation ────────────────────────────────────────────────── + +// razorpayPayment implements Payment by delegating to razorpay-go. Each +// method spawns a goroutine for the SDK call and selects on ctx.Done so a +// caller can bail out on timeout even though the SDK itself does not accept +// a context. +type razorpayPayment struct { + cfg RazorpayConfig +} + +// newRazorpayPayment returns a Payment-implementing Razorpay client. The +// caller is responsible for deciding *whether* to construct one — see +// main.Run, which falls back to noopPayment when credentials aren't set. +func newRazorpayPayment(cfg RazorpayConfig) razorpayPayment { + return razorpayPayment{cfg: cfg} +} + +func (p razorpayPayment) Configured() bool { + return p.cfg.KeyID != "" && p.cfg.KeySecret != "" +} + +type razorpayResult struct { + data map[string]interface{} + err error +} + +func (p razorpayPayment) runSDK(ctx context.Context, fn func(*razorpay.Client) (map[string]interface{}, error)) (map[string]interface{}, error) { + resCh := make(chan razorpayResult, 1) + go func() { + c := newRazorpayClient(p.cfg) + data, err := fn(c) + resCh <- razorpayResult{data: data, err: err} + }() + select { + case r := <-resCh: + return r.data, r.err + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (p razorpayPayment) CreateOrder(ctx context.Context, data map[string]interface{}) (map[string]interface{}, error) { + return p.runSDK(ctx, func(c *razorpay.Client) (map[string]interface{}, error) { + return c.Order.Create(data, nil) + }) +} + +func (p razorpayPayment) FetchOrder(ctx context.Context, orderID string) (map[string]interface{}, error) { + return p.runSDK(ctx, func(c *razorpay.Client) (map[string]interface{}, error) { + return c.Order.Fetch(orderID, nil, nil) + }) +} + +func (p razorpayPayment) CreateSubscription(ctx context.Context, data map[string]interface{}) (map[string]interface{}, error) { + return p.runSDK(ctx, func(c *razorpay.Client) (map[string]interface{}, error) { + return c.Subscription.Create(data, nil) + }) +} + +func (p razorpayPayment) CancelSubscription(ctx context.Context, subID string, opts map[string]interface{}) (map[string]interface{}, error) { + return p.runSDK(ctx, func(c *razorpay.Client) (map[string]interface{}, error) { + return c.Subscription.Cancel(subID, opts, nil) + }) +} + +func (p razorpayPayment) ListSubscriptions(ctx context.Context, opts map[string]interface{}) (map[string]interface{}, error) { + return p.runSDK(ctx, func(c *razorpay.Client) (map[string]interface{}, error) { + return c.Subscription.All(opts, nil) + }) +} + +// VerifyWebhookSignature confirms hex(HMAC-SHA256(body, WebhookSecret)) +// matches the X-Razorpay-Signature header in constant time. Razorpay does +// not prefix a timestamp (unlike Stripe) so the body alone is the MAC input. +func (p razorpayPayment) VerifyWebhookSignature(body []byte, signature string) bool { + if p.cfg.WebhookSecret == "" { + return false + } + h := hmac.New(sha256.New, []byte(p.cfg.WebhookSecret)) + h.Write(body) + expected := hex.EncodeToString(h.Sum(nil)) + return hmac.Equal([]byte(signature), []byte(expected)) +} From 7f8bcc1cea2861ff8aa426797f9b9ab4282d7566 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 23 Apr 2026 22:19:58 +0530 Subject: [PATCH 5/6] chore: add OSS scaffolding (LICENSE, README, CI, CONTRIBUTING, Makefile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything needed to open-source the repo without losing context: - LICENSE (MIT) - README.md: rewritten — one-curl demo up top, what ships today, docker + bare-go quick starts, project layout, endpoint table, links to CONTRIBUTING + LICENSE - CONTRIBUTING.md: dev setup, test commands, code layout tour, PR conventions, code style - Makefile: run / build / test / vet / fmt / lint / docker targets - config.example.yaml: sanitised template with env-override notes per field — points self-hosters at the values they must set - .github/workflows/ci.yml: go vet + gofmt check + go test + build - .github/ISSUE_TEMPLATE/{bug,feature}.md + pull_request_template.md Also: - .gitignore: add bin/, config.yaml, IDE noise; remove stale "single binary" comment - docker-compose.yml: fix schema.sql path (moved to internal/server/ with the package restructure) go build / go vet / go test pass; no code changes. --- .github/ISSUE_TEMPLATE/bug.md | 30 +++++ .github/ISSUE_TEMPLATE/feature.md | 22 ++++ .github/pull_request_template.md | 15 +++ .github/workflows/ci.yml | 36 +++++ .gitignore | 9 +- CONTRIBUTING.md | 88 +++++++++++++ LICENSE | 21 +++ Makefile | 47 +++++++ README.md | 209 ++++++++++++------------------ config.example.yaml | 97 ++++++++++++++ docker-compose.yml | 2 +- 11 files changed, 451 insertions(+), 125 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/feature.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 config.example.yaml diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..3d51486 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,30 @@ +--- +name: Bug +about: Something broke — include the failing request and response. +labels: bug +--- + +## What happened + + + +## Reproducer + +```bash +# the exact curl (or Go snippet) that fails, copy-pasteable +``` + +## Expected vs. actual + +- Expected: ... +- Actual: ... + +## Environment + +- Version / commit SHA: +- Go version (`go version`): +- Running via: docker compose / `make run` / binary / other: + +## Logs + + diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md new file mode 100644 index 0000000..88f829b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.md @@ -0,0 +1,22 @@ +--- +name: Feature / change proposal +about: Propose something new or a change to existing behaviour. +labels: enhancement +--- + +## What + + + +## Why + + + +## Scope + + + +## Alternatives considered + + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..05e8793 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ + + +## Changes + +- ... + +## Test plan + +- [ ] `make test` passes locally +- [ ] `make vet` passes locally +- [ ] tried the end-to-end flow manually (if user-visible) + +## Notes for reviewer + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cdc0a7b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: go vet + run: go vet ./... + + - name: gofmt + run: | + diff=$(gofmt -d .) + if [ -n "$diff" ]; then + echo "gofmt wants these changes:" + echo "$diff" + exit 1 + fi + + - name: go test + run: go test ./... + + - name: build + run: go build ./... diff --git a/.gitignore b/.gitignore index 72436bf..a60438c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,15 @@ .env config.prod.yaml config.local.yaml +config.yaml spec.yaml -# Binary +# Binaries +bin/ instant-lite-api lite + +# IDE +.vscode/ +.idea/ +*.swp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..82a578a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing + +Thanks for the interest. This project is early and small — the fastest way to +help is to file a bug with a reproducer, or open a PR that's scoped tightly. + +## Development setup + +Prereqs: Go 1.25+, a Postgres you can create databases on, Redis (optional — +the server degrades without it). + +```bash +git clone https://github.com/InstaNode-dev/instant-lite-api +cd instant-lite-api +cp config.example.yaml config.yaml # edit the DB urls +make run +``` + +Or with Docker: + +```bash +make docker-up +curl -s -X POST http://localhost:18080/db/new | jq +``` + +## Running the test suite + +```bash +make test # unit tests, no external services needed +make vet # go vet + gofmt check +``` + +Unit tests stub out Razorpay via the `razorpayBaseURLOverride` pointer in +`internal/server/razorpay_client.go` — no test ever makes a real network call. + +## Code layout + +``` +cmd/server/ entrypoint — thin main() that calls server.Run +internal/server/ everything else (handlers, auth, billing, config, db) + paths.go route-path constants (reuse these, don't hardcode) + payment.go billing-provider interface + razorpay_client.go razorpayPayment impl + SDK helpers + payment_noop.go no-op impl for self-hosts without Razorpay + billing.go shared billing helpers + deprecated migrate shim + billing_orders.go legacy one-time order flow + billing_webhook.go Razorpay webhook dispatcher + signature verify + billing_subscriptions.go subscription lifecycle handlers + billing_change_plan.go monthly↔annual plan switch + billing_reconciler.go background reconciler (polls Razorpay) + ... +``` + +## PR conventions + +- **Branch off master**, don't push to master directly (there's a pre-push + hook blocking this — enable with `git config core.hooksPath .githooks`). +- **One concern per PR**. Splitting a file, a bug fix, and a feature into + three PRs gets reviewed in three hours. Bundling them gets reviewed in + three weeks. +- **Commit messages**: `: ` (e.g. `billing: fix INR + rounding in receipt email`). Explain the *why* in the body when it's + non-obvious. +- **Tests**: add them when you add behaviour. Tests that hit a real + `httptest.Server` for external APIs, not mocks of our own types. +- **No `--no-verify` pushes**, no `gofmt` violations, no unused imports. + +## Code style + +- Follow `gofmt` — CI will fail otherwise. +- Prefer named constants over repeated string literals (paths, error codes, + header names). See `internal/server/paths.go` for the pattern. +- Errors bubble up; don't swallow. Log with `slog.ErrorContext` when the + request context is available so traces correlate. +- Return structured JSON errors via `writeError(w, status, code, message)` — + don't hand-write error bodies. + +## What we're NOT looking for + +- Abstractions-for-future-flexibility without a concrete second caller today. +- Rewrites of working code to match a different style. +- AI-generated PR descriptions or commit messages — write your own so we + can review intent, not output. + +## Getting unstuck + +Open an issue with the full command you ran and the full error output. We +don't need a stack trace dump of your entire server — just the failing +request + response. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..735395a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InstaNode Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bef131e --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +.PHONY: run build test vet lint fmt clean docker docker-up docker-down help + +# Default target +help: + @echo "instant-lite-api — make targets:" + @echo " run start the server (reads config.yaml)" + @echo " build compile binary to bin/instant-lite" + @echo " test run the Go test suite" + @echo " vet go vet + gofmt check" + @echo " fmt format sources in place" + @echo " lint strict checks (vet + gofmt --diff)" + @echo " docker build the Docker image" + @echo " docker-up docker compose up -d --build" + @echo " docker-down docker compose down -v" + @echo " clean remove build artefacts" + +run: + go run ./cmd/server + +build: + @mkdir -p bin + CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/instant-lite ./cmd/server + +test: + go test ./... + +vet: + go vet ./... + @gofmt -l . | (! grep .) || (echo "gofmt wants changes — run 'make fmt'" && exit 1) + +fmt: + gofmt -w . + +lint: vet + @gofmt -d . | (! grep .) || (echo "gofmt diff above — run 'make fmt'" && exit 1) + +docker: + docker build -t instant-lite-api:local . + +docker-up: + docker compose up -d --build + +docker-down: + docker compose down -v + +clean: + rm -rf bin/ lite instant-lite-api diff --git a/README.md b/README.md index 2dcbd98..13311a1 100644 --- a/README.md +++ b/README.md @@ -1,163 +1,126 @@ # instant-lite-api -Backend API for instanode.dev — provisions real Postgres databases, Redis caches, and webhook -receivers with one HTTP call. No account, no Docker, no configuration. +Zero-setup database provisioning over HTTP. Single binary, one curl, real +Postgres connection string. Built for AI coding agents and local prototyping. -## Git hooks - -`.githooks/pre-push` blocks direct pushes to `master` to keep changes flowing -through PRs. Server-side protection is paywalled on GitHub's Free plan for -private repos, so this hook is the local stand-in. Opt in once after cloning: - -```sh -git config core.hooksPath .githooks +```bash +curl -X POST http://localhost:8080/db/new +# → { "connection_url": "postgres://...", "expires_at": "...", ... } ``` -Emergency bypass: `git push --no-verify`. +Same shape for webhook receivers (`POST /webhook/new`). No signup, no +Docker required in the caller's environment, no dashboard to navigate. -## Architecture +## What ships today -``` -instanode.dev (GitHub Pages) ← Static HTML/CSS/JS (instant-lite-web/) - │ - │ curl commands point to: - ▼ -api.instanode.dev (bare metal / Fly.io) ← This repo - │ - ├── Postgres (CREATE DATABASE per token) - └── Redis (ACL SETUSER per token) -``` +- `POST /db/new` — provisions an isolated Postgres database (per-token + CREATE DATABASE + CREATE USER + CONNECTION LIMIT) +- `POST /webhook/new` + `GET/POST /webhook/receive/{token}` — webhook + receiver for testing third-party callbacks +- GitHub OAuth login, `/claim` to convert anonymous 24h resources into + permanent ones +- Razorpay-based billing (optional — runs fine without it) -The website is hosted separately on GitHub Pages. This repo is the API only. -Website traffic surges never affect provisioning. +Redis, Mongo, queue, and object storage provisioning are on the roadmap. +See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the production deployment +layout. -## Quick start (docker compose) +## Quick start + +### Docker Compose ```bash -docker compose up -d --build +git clone https://github.com/InstaNode-dev/instant-lite-api +cd instant-lite-api +make docker-up curl -s -X POST http://localhost:18080/db/new | jq -curl -s -X POST http://localhost:18080/cache/new | jq curl -s -X POST http://localhost:18080/webhook/new | jq curl -s http://localhost:18080/healthz | jq -docker compose down -v +make docker-down ``` -Docker Compose mounts `config.docker.yaml` into the container automatically. +`docker-compose.yml` mounts `config.docker.yaml` into the container +automatically — it's pre-configured for the bundled Postgres + Redis. + +### Bare Go -## Quick start (local, no Docker) +Prereqs: Go 1.25+, Postgres, Redis (optional). ```bash -# Prerequisites: Go 1.24+, Postgres, Redis +cp config.example.yaml config.yaml # edit the DB urls createdb instant_lite -psql instant_lite < schema.sql -redis-server & +psql instant_lite < internal/server/schema.sql -# Edit config.yaml to match your local setup, then: -go run . +make run ``` ## Configuration -All settings live in `config.yaml`. The only environment variable is `CONFIG_PATH` -(defaults to `config.yaml`) to locate the config file. - -```yaml -server: - port: "8080" - base_url: "" # Auto-derived if empty - read_timeout: "10s" - write_timeout: "30s" - idle_timeout: "60s" - -database: - platform_url: "postgres://instant:instant@localhost:5432/instant_lite?sslmode=disable" - customer_url: "" # Falls back to platform_url if empty - max_open_conns: 20 - max_idle_conns: 5 - -redis: - url: "redis://localhost:6379" - -limits: - rate_requests_per_second: 10 # HTTP rate limit (token bucket) - rate_burst: 20 # Max burst - max_provisions_per_day: 5 # Per-IP/subnet daily cap - anon_ttl: "24h" # TTL for anonymous resources - max_request_body_bytes: 1048576 - webhook_max_body_bytes: 1048576 - webhook_max_stored: 100 - ipv4_cidr_prefix: 24 # Subnet grouping for fingerprinting - ipv6_cidr_prefix: 48 - -reaper: - interval: "5m" # Cleanup frequency - batch_size: 50 # Max resources cleaned per cycle - timeout: "60s" # Context timeout per cycle - -postgres: - conn_limit: 2 # CONNECTION LIMIT per provisioned DB - storage_mb: 10 # Storage quota hint - statement_timeout: "30s" # Max query time per provisioned user -``` +Everything lives in `config.yaml`. `CONFIG_PATH` is the only env var the +binary reads directly; secrets are picked up from their documented env +overrides (see [`config.example.yaml`](config.example.yaml) for the full +list). -For Docker deployments, edit `config.docker.yaml` (hostnames differ inside containers). +Three commonly-adjusted fields for self-hosters: -## Deploy to Fly.io +| Field | What it controls | +|---|---| +| `server.base_url` | Public API URL this binary serves. Baked into webhook receive URLs emitted to clients. | +| `server.marketing_url` | Public website URL for post-OAuth redirects + upgrade CTAs. Empty ⇒ those paths 404, binary runs fine. | +| `server.cookie_domain` | Session cookie `Domain`. Empty = host-only. Set to a registrable domain to share across `api.example.com` + `example.com`. | -```bash -fly launch --copy-config --no-deploy --name instant-lite-api --region iad -fly postgres create --name instant-lite-db --region iad --vm-size shared-cpu-1x -fly redis create --name instant-lite-redis --region iad --plan free -fly postgres attach instant-lite-db -a instant-lite-api -fly redis attach instant-lite-redis -a instant-lite-api -# Upload your config.yaml as a secret or mount via fly.toml -fly deploy -fly ssh console -a instant-lite-api -C "psql \$DATABASE_URL -f /app/schema.sql" -``` +Billing is optional: leave `razorpay.*` empty and the payment endpoints +return 503 instead of calling out. -## Deploy to bare metal / VPS +## Project layout -```bash -CGO_ENABLED=0 go build -o instant-lite-api . -scp instant-lite-api schema.sql config.yaml user@yourserver:~/ +``` +cmd/server/ thin main() entrypoint +internal/server/ everything else — handlers, auth, billing, db, etc. + paths.go route-path constants + payment.go billing-provider interface + razorpay_client.go razorpayPayment impl + SDK helpers + payment_noop.go no-op impl (when billing unconfigured) + billing*.go orders / webhook / subscriptions / change-plan / reconciler + handlers.go /db/new, /webhook/new provisioning + auth.go GitHub OAuth, JWT sessions + reaper.go background cleanup of expired resources +``` -# On server: -sudo -u postgres createdb instant_lite -psql instant_lite < schema.sql +## Shipped endpoints -# Edit config.yaml with production values, then: -./instant-lite-api -``` +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/db/new` | Provision a Postgres database | +| `POST` | `/webhook/new` | Provision a webhook receiver | +| `POST`/`GET` | `/webhook/receive/{token}` | Receive webhook payloads | +| `GET` | `/auth/github/login` | Start OAuth login | +| `GET` | `/auth/me` | Current session user | +| `POST` | `/api/me/claim` | Claim an anonymous resource into an account | +| `GET` | `/api/me/resources` | List the caller's resources | +| `GET` | `/healthz` | Liveness | +| `GET` | `/readyz` | Readiness (pings all downstream deps) | +| `GET` | `/openapi.json` | OpenAPI 3.1 schema | +| `GET` | `/llms.txt` | Machine-readable docs for AI agents | -Put Caddy in front for automatic HTTPS: -``` -# /etc/caddy/Caddyfile -api.instanode.dev { - reverse_proxy localhost:8080 -} -``` +Full spec at `GET /openapi.json`. + +## Deploying -## Endpoints +- **Fly.io**: `fly.toml` and `spec.yaml.tpl` included; `fly launch + --copy-config --no-deploy` to start. +- **DigitalOcean App Platform**: `spec.yaml.tpl` is a DO App spec template; + `deploy-do.sh` shows the full path. +- **Bare VPS**: `make build` → copy `bin/instant-lite` + `config.yaml` + + `schema.sql` → run behind Caddy or any reverse proxy. -| Method | Path | What it does | -|--------|------|-------------| -| POST | `/db/new` | Provision a Postgres database | -| POST | `/cache/new` | Provision a Redis cache | -| POST | `/webhook/new` | Provision a webhook receiver | -| POST | `/webhook/receive/{token}` | Receive a webhook payload | -| GET | `/healthz` | Health check | -| GET | `/llms.txt` | Machine-readable docs for AI agents | -| GET | `/` | 302 redirect to https://instanode.dev | +## Contributing -## Security +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for dev setup, PR conventions, and +code style. TL;DR: `make test && make vet` before every push. -All security parameters are configurable via `config.yaml`: +## License -- **Daily provision limit:** Configurable per IP/subnet (atomic Redis counter, Postgres fallback) -- **HTTP rate limit:** Configurable req/s per IP (token bucket) -- **Request body limit:** Configurable max bytes -- **Postgres isolation:** Separate database + user per token, configurable CONNECTION LIMIT and statement_timeout -- **Redis isolation:** ACL user per token, key-prefix enforcement -- **Expired resource cleanup:** Background reaper with configurable interval and batch size +MIT. See [`LICENSE`](LICENSE). diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..a6ef8d3 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,97 @@ +# instant-lite-api configuration — sanitised example. +# Copy to config.yaml and fill in real values before running. +# +# The only environment variable the binary reads is CONFIG_PATH, defaulting +# to ./config.yaml. Secret overrides are documented per-field below; empty +# values in this file are picked up from the environment if set. + +server: + port: "8080" + base_url: "http://localhost:8080" # public API URL this binary serves + marketing_url: "http://localhost:5173" # public website (redirects, emails, upgrade CTAs). Empty ⇒ those paths 404. + cookie_domain: "" # e.g. "example.com" to share session across api.example.com + example.com. Empty = host-only cookie (right default for single host + local dev). + allowed_origins: # CORS: exact-match allowlist echoed into Access-Control-Allow-Origin + - "http://localhost:5173" + - "http://localhost:3000" + read_timeout: "10s" + write_timeout: "30s" + idle_timeout: "60s" + +database: + # Platform DB — stores users, resources, webhook deliveries, billing state. + platform_url: "" # env override: DATABASE_URL + # Customer DB — where we CREATE DATABASE and CREATE USER per token. Usually a + # separate Postgres from the platform DB for blast-radius reasons. + customer_url: "" # env override: CUSTOMER_DATABASE_URL + max_open_conns: 20 + max_idle_conns: 5 + +redis: + # Valkey/Redis backend for rate limits + webhook payload storage. Empty URL + # puts the server into degraded mode (rate limiting + webhook storage fall + # back to Postgres or no-op; webhook lookups may be slower). + url: "redis://localhost:6379" # env override: REDIS_URL + +github: + # GitHub OAuth app. Create one at github.com/settings/developers. + client_id: "" # env override: GITHUB_CLIENT_ID + client_secret: "" # env override: GITHUB_CLIENT_SECRET + redirect_uri: "http://localhost:8080/auth/github/callback" + +razorpay: + # Leave blank to run without billing — endpoints that need it will 503 and + # the noopPayment provider is installed automatically. + key_id: "" # env override: RAZORPAY_KEY_ID + key_secret: "" # env override: RAZORPAY_KEY_SECRET + webhook_secret: "" # env override: RAZORPAY_WEBHOOK_SECRET + # Plan IDs from your Razorpay dashboard. USD/INR split supported; legacy + # PlanIDMonthly/PlanIDAnnual fields serve as USD fallbacks. + plan_id_usd_monthly: "" + plan_id_usd_yearly: "" + plan_id_inr_monthly: "" + plan_id_inr_yearly: "" + +jwt: + # Sessions + API tokens. Generate via: openssl rand -hex 32 + secret: "" # env override: JWT_SECRET (32+ bytes) + +limits: + rate_requests_per_second: 10 + rate_burst: 20 + max_provisions_per_day: 5 # per /24 subnet (IPv6: /48) + anon_ttl: "24h" + max_request_body_bytes: 1048576 + webhook_max_body_bytes: 1048576 + webhook_max_stored: 100 + ipv4_cidr_prefix: 24 + ipv6_cidr_prefix: 48 + +reaper: + interval: "5m" + batch_size: 50 + timeout: "60s" + +postgres: + # Quotas applied to each provisioned database. + conn_limit: 2 + storage_mb: 10 + statement_timeout: "30s" + +email: + # Leave smtp_host empty to disable email sending (no-op SendAsync). + smtp_host: "" + smtp_port: 587 + smtp_user: "" + smtp_pass: "" + from_address: "no-reply@example.com" + from_name: "instant-lite" + +observability: + # OpenTelemetry. Works with any OTLP backend (New Relic, Datadog, Grafana). + enabled: false + service_name: "instant-lite-api" + environment: "development" + exporter: "otlp" # "otlp" or "stdout" + otlp_endpoint: "localhost:4318" + otlp_insecure: true + sample_rate: 1.0 diff --git a/docker-compose.yml b/docker-compose.yml index ade7c75..7010aea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: ports: - "15432:5432" volumes: - - ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro + - ./internal/server/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U instant -d instant_lite"] interval: 2s From 7613cfe958308fe7d1ded139210d917038d220f6 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Thu, 23 Apr 2026 22:20:33 +0530 Subject: [PATCH 6/6] style: gofmt -w entire package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings every .go file under gofmt's rules — some files had pre-existing misalignments (struct tag columns, tab/space mixes in const blocks, missing trailing newlines) that the OSS CI workflow now catches. No code changes. --- internal/server/auth.go | 2 +- internal/server/billing_change_plan.go | 20 ++++----- internal/server/billing_emails.go | 3 +- internal/server/billing_razorpay_sdk_test.go | 3 +- internal/server/billing_reconciler.go | 44 ++++++++++---------- internal/server/billing_test.go | 16 +++---- internal/server/config.go | 4 +- internal/server/dashboard.go | 17 ++++---- internal/server/inbound.go | 10 ++--- internal/server/inbound_reconciler_test.go | 4 +- internal/server/inbound_test.go | 2 +- internal/server/main.go | 14 +++---- internal/server/observability.go | 2 +- internal/server/paths.go | 14 +++---- internal/server/plan_switch_test.go | 2 +- internal/server/plan_test.go | 18 ++++---- 16 files changed, 88 insertions(+), 87 deletions(-) diff --git a/internal/server/auth.go b/internal/server/auth.go index cd71ef9..bd761f5 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -351,4 +351,4 @@ func (s *server) handleMe(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(user) -} \ No newline at end of file +} diff --git a/internal/server/billing_change_plan.go b/internal/server/billing_change_plan.go index 3f74789..d0d6925 100644 --- a/internal/server/billing_change_plan.go +++ b/internal/server/billing_change_plan.go @@ -64,12 +64,12 @@ import ( type planSwitchDecision int const ( - planSwitchOK planSwitchDecision = iota - planSwitchFeatureOff // 404 - planSwitchInvalidTarget // 400 - planSwitchNotActive // 409 (no active subscription) - planSwitchAlreadyOnPlan // 409 (target == current) - planSwitchAlreadyPending // 409 (another switch in flight) + planSwitchOK planSwitchDecision = iota + planSwitchFeatureOff // 404 + planSwitchInvalidTarget // 400 + planSwitchNotActive // 409 (no active subscription) + planSwitchAlreadyOnPlan // 409 (target == current) + planSwitchAlreadyPending // 409 (another switch in flight) ) // decidePlanSwitchRequest is the pure core of POST /billing/change-plan. @@ -110,10 +110,10 @@ func decidePlanSwitchRequest( type cancelPlanSwitchDecision int const ( - cancelSwitchOK cancelPlanSwitchDecision = iota - cancelSwitchFeatureOff // 404 - cancelSwitchNothingPending // 409 (no pending_plan_change) - cancelSwitchAlreadyFired // 409 (reconciler already created new sub — too late) + cancelSwitchOK cancelPlanSwitchDecision = iota + cancelSwitchFeatureOff // 404 + cancelSwitchNothingPending // 409 (no pending_plan_change) + cancelSwitchAlreadyFired // 409 (reconciler already created new sub — too late) ) func decideCancelPlanSwitch( diff --git a/internal/server/billing_emails.go b/internal/server/billing_emails.go index 459ce47..c6e3f67 100644 --- a/internal/server/billing_emails.go +++ b/internal/server/billing_emails.go @@ -174,7 +174,8 @@ type planSwitchScheduledClaim struct { // (after the first has been cancelled + re-initiated) still fires the email. // // Slot-not-claimed = (plan_switch_scheduled_email_sent_at IS NULL -// AND pending_plan_change IS NOT NULL). +// +// AND pending_plan_change IS NOT NULL). func claimPlanSwitchScheduledEmail(ctx context.Context, db *sql.DB, userID uuid.UUID) (planSwitchScheduledClaim, bool) { var ( email string diff --git a/internal/server/billing_razorpay_sdk_test.go b/internal/server/billing_razorpay_sdk_test.go index e6cc0ea..c42e692 100644 --- a/internal/server/billing_razorpay_sdk_test.go +++ b/internal/server/billing_razorpay_sdk_test.go @@ -275,7 +275,7 @@ func TestLiveRazorpayCreateSub_INRMonthlyPicksINRPlanAndNotes(t *testing.T) { cfg := RazorpayConfig{ KeyID: "rzp_test_abc", KeySecret: "rzp_secret_xyz", - PlanIDMonthly: "plan_legacy_M", // legacy USD fallback + PlanIDMonthly: "plan_legacy_M", // legacy USD fallback PlanIDINRMonthly: "plan_inr_monthly_stub", } _, err := liveRazorpayCreateSub(context.Background(), cfg, "monthly", "INR", uuid.New()) @@ -438,4 +438,3 @@ func TestRazorpayBaseURLOverride_EmptyMeansProductionDefault(t *testing.T) { t.Errorf("prod BaseURL = %q, want https://api.razorpay.com", client.Request.BaseURL) } } - diff --git a/internal/server/billing_reconciler.go b/internal/server/billing_reconciler.go index 1d4dfa9..5c69b07 100644 --- a/internal/server/billing_reconciler.go +++ b/internal/server/billing_reconciler.go @@ -37,27 +37,27 @@ const ( ) type brevoRazorpaySubDetail struct { - ID string `json:"id"` - Status string `json:"status"` - PaidCount int `json:"paid_count"` - CurrentEnd float64 `json:"current_end"` // unix seconds - ChargeAt float64 `json:"charge_at"` - EndedAt float64 `json:"ended_at"` - ShortURL string `json:"short_url"` - PlanID string `json:"plan_id"` - Notes map[string]interface{} `json:"notes"` + ID string `json:"id"` + Status string `json:"status"` + PaidCount int `json:"paid_count"` + CurrentEnd float64 `json:"current_end"` // unix seconds + ChargeAt float64 `json:"charge_at"` + EndedAt float64 `json:"ended_at"` + ShortURL string `json:"short_url"` + PlanID string `json:"plan_id"` + Notes map[string]interface{} `json:"notes"` } // reconcileAction is what the reconciler decides to do per user. type reconcileAction string const ( - actionNone reconcileAction = "" // already in sync - actionActivate reconcileAction = "activate" // promote to paid + send receipt - actionHalt reconcileAction = "halt" // downgrade + send failure email - actionCancel reconcileAction = "cancel" // mark cancelled (keep paid until period end) - actionComplete reconcileAction = "complete" // total_count reached - actionClear reconcileAction = "clear" // stuck created → forget; user can retry + actionNone reconcileAction = "" // already in sync + actionActivate reconcileAction = "activate" // promote to paid + send receipt + actionHalt reconcileAction = "halt" // downgrade + send failure email + actionCancel reconcileAction = "cancel" // mark cancelled (keep paid until period end) + actionComplete reconcileAction = "complete" // total_count reached + actionClear reconcileAction = "clear" // stuck created → forget; user can retry ) // decideBillingReconcile compares the user's DB state against the subscription @@ -166,13 +166,13 @@ func reconcileBillingOnce(ctx context.Context, db *sql.DB, cfg *Config, em *emai defer rows.Close() type row struct { - userID uuid.UUID - email string - tier string - period string - paidAt *time.Time - status *string - subID string + userID uuid.UUID + email string + tier string + period string + paidAt *time.Time + status *string + subID string } var users []row for rows.Next() { diff --git a/internal/server/billing_test.go b/internal/server/billing_test.go index 3447bf0..557b8ca 100644 --- a/internal/server/billing_test.go +++ b/internal/server/billing_test.go @@ -136,9 +136,9 @@ func TestUserIDFromNotes_Malformed(t *testing.T) { {"user_id": "not-a-uuid"}, {"user_id": "12345"}, {"user_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}, - {"user_id": 12345}, // wrong type — int, not string - {"user_id": nil}, // wrong type — nil - {"user_id": []string{}}, // wrong type — slice + {"user_id": 12345}, // wrong type — int, not string + {"user_id": nil}, // wrong type — nil + {"user_id": []string{}}, // wrong type — slice } for i, notes := range cases { got, ok := userIDFromNotes(notes) @@ -190,11 +190,11 @@ func TestUnixToTime_Nil(t *testing.T) { func TestUnixToTime_UnsupportedTypes(t *testing.T) { cases := []interface{}{ - "1713446400", // string — not accepted - int(1713446400), // plain int — not accepted (only int64) - int32(1713446400), // int32 — not accepted - map[string]int{}, // bogus type - []byte("whatever"), // bogus type + "1713446400", // string — not accepted + int(1713446400), // plain int — not accepted (only int64) + int32(1713446400), // int32 — not accepted + map[string]int{}, // bogus type + []byte("whatever"), // bogus type } for i, v := range cases { got := unixToTime(v) diff --git a/internal/server/config.go b/internal/server/config.go index fe30abf..27ce514 100644 --- a/internal/server/config.go +++ b/internal/server/config.go @@ -74,7 +74,7 @@ type ObservabilityConfig struct { Enabled bool `yaml:"enabled"` ServiceName string `yaml:"service_name"` Environment string `yaml:"environment"` - Exporter string `yaml:"exporter"` // "otlp" or "stdout" + Exporter string `yaml:"exporter"` // "otlp" or "stdout" OTLPEndpoint string `yaml:"otlp_endpoint"` OTLPHeaders map[string]string `yaml:"otlp_headers"` OTLPInsecure bool `yaml:"otlp_insecure"` // true for local collectors @@ -346,7 +346,7 @@ func (c *Config) overrideWithEnv() { c.JWT.Secret = v } } - if c.Database.PlatformURL == "" { + if c.Database.PlatformURL == "" { if v := os.Getenv("DATABASE_URL"); v != "" { c.Database.PlatformURL = v } diff --git a/internal/server/dashboard.go b/internal/server/dashboard.go index 69706a7..f4d4769 100644 --- a/internal/server/dashboard.go +++ b/internal/server/dashboard.go @@ -13,14 +13,14 @@ import ( ) type Resource struct { - ID uuid.UUID `json:"id"` - Token uuid.UUID `json:"token"` - Type string `json:"type"` - Name string `json:"name"` - Tier string `json:"tier"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - ExpiresAt *time.Time `json:"expires_at"` + ID uuid.UUID `json:"id"` + Token uuid.UUID `json:"token"` + Type string `json:"type"` + Name string `json:"name"` + Tier string `json:"tier"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at"` } func (s *server) handleGetResources(w http.ResponseWriter, r *http.Request) { @@ -378,6 +378,7 @@ func (s *server) handleClaimToken(w http.ResponseWriter, r *http.Request) { "status": "active", }) } + // ── Pure helpers (testable without DB / HTTP) ─────────────────────────────── // buildHumanPlanLabel renders the one-line plan label surfaced on the diff --git a/internal/server/inbound.go b/internal/server/inbound.go index edd3109..963d4a6 100644 --- a/internal/server/inbound.go +++ b/internal/server/inbound.go @@ -44,7 +44,7 @@ const maxInboundBodyBytes = 10 * 1024 * 1024 // item. All fields are already trimmed/normalised; headers marshal directly // into the JSONB column. type inboundMessage struct { - ProviderID string // Brevo MessageId (or fallback) + ProviderID string // Brevo MessageId (or fallback) FromEmail string FromName string ToEmail string @@ -105,10 +105,10 @@ func parseBrevoPayload(body []byte) ([]inboundMessage, error) { func flattenItem(it brevoItem) inboundMessage { msg := inboundMessage{ - FromEmail: strings.TrimSpace(it.From.Address), - FromName: strings.TrimSpace(it.From.Name), - Subject: it.Subject, - SpamScore: it.SpamScore, + FromEmail: strings.TrimSpace(it.From.Address), + FromName: strings.TrimSpace(it.From.Name), + Subject: it.Subject, + SpamScore: it.SpamScore, RawHeaders: it.Headers, } diff --git a/internal/server/inbound_reconciler_test.go b/internal/server/inbound_reconciler_test.go index 8fc8d22..99bef8c 100644 --- a/internal/server/inbound_reconciler_test.go +++ b/internal/server/inbound_reconciler_test.go @@ -11,9 +11,9 @@ func TestParsedReconcileInterval(t *testing.T) { in string want time.Duration }{ - {"", 10 * time.Minute}, // default + {"", 10 * time.Minute}, // default {"not-a-duration", 10 * time.Minute}, // invalid → default - {"30s", 10 * time.Minute}, // below 1m floor → default + {"30s", 10 * time.Minute}, // below 1m floor → default {"5m", 5 * time.Minute}, {"1h", 1 * time.Hour}, } diff --git a/internal/server/inbound_test.go b/internal/server/inbound_test.go index d3d2262..b1be1a0 100644 --- a/internal/server/inbound_test.go +++ b/internal/server/inbound_test.go @@ -215,7 +215,7 @@ func TestBrevoInbound_TokenConstantTime(t *testing.T) { s.handleBrevoInbound(rec, req) return time.Since(start) } - d1 := call("a") // short mismatch + d1 := call("a") // short mismatch d2 := call("completely-different-long-wrong") // long mismatch // Both should complete in < 100ms — we're really just asserting the code // path ran and didn't short-circuit with a length-dependent string compare diff --git a/internal/server/main.go b/internal/server/main.go index 1f3c78f..c6ab8d1 100644 --- a/internal/server/main.go +++ b/internal/server/main.go @@ -27,13 +27,13 @@ var llmsTxt []byte var schemaSQL string type server struct { - db *sql.DB - rdb *redis.Client // Valkey (rate limits, webhook storage, and where - // per-tenant ACL users are provisioned) + db *sql.DB + rdb *redis.Client // Valkey (rate limits, webhook storage, and where + // per-tenant ACL users are provisioned) cfg *Config - baseURL string // API host, e.g. https://api.example.com — served by this binary - marketingURL string // public website host, e.g. https://example.com — served elsewhere - custDBURL string // customer Postgres (where we CREATE DATABASE) + baseURL string // API host, e.g. https://api.example.com — served by this binary + marketingURL string // public website host, e.g. https://example.com — served elsewhere + custDBURL string // customer Postgres (where we CREATE DATABASE) email *emailer payment Payment // billing provider (Razorpay in prod, no-op when unconfigured) } @@ -335,7 +335,7 @@ func panicRecoveryMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { - // We use ErrorContext specifically so otelslog can automatically grab the + // We use ErrorContext specifically so otelslog can automatically grab the // Trace ID / Span ID strictly injected by the otelhttp middleware. slog.ErrorContext(r.Context(), "FATAL PANIC", "error", err, "stack", string(debug.Stack())) writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": "internal_server_error", "message": "An unexpected error occurred."}) diff --git a/internal/server/observability.go b/internal/server/observability.go index 554bc0e..ab9e471 100644 --- a/internal/server/observability.go +++ b/internal/server/observability.go @@ -124,7 +124,7 @@ func initObservability(cfg *Config) func(context.Context) { log.WithResource(res), ) global.SetLoggerProvider(lp) - + // Map standard slog events to the OTel logger bridge otelSlogHandler := otelslog.NewHandler(cfg.Observability.ServiceName) slog.SetDefault(slog.New(otelSlogHandler)) diff --git a/internal/server/paths.go b/internal/server/paths.go index c45dfd6..7f533ea 100644 --- a/internal/server/paths.go +++ b/internal/server/paths.go @@ -7,13 +7,13 @@ package server const ( // Marketing site paths (rendered by the static website, not this binary). // Concatenated with server.Config.MarketingURL at call sites. - pathMarketingHome = "/" - pathMarketingDashboard = "/dashboard" - pathMarketingDashboardPage = "/dashboard.html" - pathMarketingPricing = "/pricing" - pathMarketingPricingPage = "/pricing.html" - pathMarketingStart = "/start" - pathMarketingStartErrorQS = "/start?error=" + pathMarketingHome = "/" + pathMarketingDashboard = "/dashboard" + pathMarketingDashboardPage = "/dashboard.html" + pathMarketingPricing = "/pricing" + pathMarketingPricingPage = "/pricing.html" + pathMarketingStart = "/start" + pathMarketingStartErrorQS = "/start?error=" // API paths served by this binary. Concatenated with server.baseURL when // surfaced back to clients (e.g. dashboard JSON, emitted webhook URLs). diff --git a/internal/server/plan_switch_test.go b/internal/server/plan_switch_test.go index 03d65d1..2ddf830 100644 --- a/internal/server/plan_switch_test.go +++ b/internal/server/plan_switch_test.go @@ -17,7 +17,7 @@ import ( // Pure decision helpers: decidePlanSwitchRequest // ───────────────────────────────────────────────────────────────────────────── -func strPtr(s string) *string { return &s } +func strPtr(s string) *string { return &s } func timePtr(t time.Time) *time.Time { return &t } func TestDecidePlanSwitchRequest_FeatureOff(t *testing.T) { diff --git a/internal/server/plan_test.go b/internal/server/plan_test.go index d67e1cd..16390c9 100644 --- a/internal/server/plan_test.go +++ b/internal/server/plan_test.go @@ -140,13 +140,13 @@ func TestPlanConfig_UnknownCurrencyCoercesToUSD(t *testing.T) { func TestNormalizeCurrency(t *testing.T) { tests := map[string]string{ - "": "USD", - "USD": "USD", - "usd": "USD", - " INR ": "INR", - "inr": "INR", - "EUR": "USD", // unknown → USD by design - "gbp": "USD", + "": "USD", + "USD": "USD", + "usd": "USD", + " INR ": "INR", + "inr": "INR", + "EUR": "USD", // unknown → USD by design + "gbp": "USD", "nonsense": "USD", } for in, want := range tests { @@ -249,8 +249,8 @@ func TestSubscriptionStatusBlocksNew(t *testing.T) { {s("halted"), false}, {s("completed"), false}, {s("expired"), false}, - {s("created"), false}, // short_url reserved, not yet authenticated — safe to replace - {s("pending"), false}, // retry in flight, safe to replace + {s("created"), false}, // short_url reserved, not yet authenticated — safe to replace + {s("pending"), false}, // retry in flight, safe to replace {s("active"), true}, {s("authenticated"), true}, {s(" ACTIVE "), true}, // whitespace + case insensitive