From 64f36ad642bea46e491ed2fe20f6ebdb949c8320 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 7 Jun 2026 13:46:17 +0800 Subject: [PATCH] feat(enterprise): align account limits API --- cmd/dump_routes/main.go | 19 +- cmd/route_parity/main.go | 5 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 +- docs/parity/gochat_routes.txt | 7 +- docs/parity/route_parity.md | 7 +- internal/app/bootstrap.go | 1 + .../api/v1/enterprise_account_handler.go | 122 +++++++++++ .../api/v1/enterprise_account_handler_test.go | 174 +++++++++++++++ internal/repository/account_repo.go | 49 +++++ internal/router/router.go | 22 ++ internal/service/account_service.go | 207 ++++++++++++++++++ 11 files changed, 610 insertions(+), 17 deletions(-) create mode 100644 internal/handler/api/v1/enterprise_account_handler.go create mode 100644 internal/handler/api/v1/enterprise_account_handler_test.go diff --git a/cmd/dump_routes/main.go b/cmd/dump_routes/main.go index 6160d988..4834ec52 100644 --- a/cmd/dump_routes/main.go +++ b/cmd/dump_routes/main.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/config" + v1 "github.com/gochat/gochat/internal/handler/api/v1" "github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/router" ) @@ -22,21 +23,21 @@ func main() { os.Exit(1) } - // Create a zero-value Handlers struct — we only need route paths, not working handlers - handlers := &router.Handlers{} + // Create mostly zero-value handlers — we only need route paths, not working handlers. + handlers := &router.Handlers{EnterpriseAccount: v1.NewEnterpriseAccountHandler(nil)} corsCfg := middleware.CORSConfigFromAppConfig(cfg) router.RegisterRoutes( engine, - nil, // jwtService - nil, // refreshStore - nil, // webhookRegistry + nil, // jwtService + nil, // refreshStore + nil, // webhookRegistry handlers, - nil, // hub - nil, // wsAuthenticator + nil, // hub + nil, // wsAuthenticator &cfg.JWT, corsCfg, - nil, // db + nil, // db ) routes := engine.Routes() @@ -56,4 +57,4 @@ func main() { count++ } fmt.Printf("TOTAL: %d\n", count) -} \ No newline at end of file +} diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index de64a774..21165eaa 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -43,6 +43,11 @@ var criticalRoutes = []route{ {Method: "GET", Path: "/google/callback", Controller: "google/callbacks#show", Source: "routes.rb:650"}, {Method: "GET", Path: "/instagram/callback", Controller: "instagram/callbacks#show", Source: "routes.rb:651"}, {Method: "GET", Path: "/tiktok/callback", Controller: "tiktok/callbacks#show", Source: "routes.rb:652"}, + {Method: "POST", Path: "/enterprise/api/v1/accounts/:account_id/checkout", Controller: "enterprise/api/v1/accounts#checkout", Source: "routes.rb:523"}, + {Method: "POST", Path: "/enterprise/api/v1/accounts/:account_id/subscription", Controller: "enterprise/api/v1/accounts#subscription", Source: "routes.rb:524"}, + {Method: "GET", Path: "/enterprise/api/v1/accounts/:account_id/limits", Controller: "enterprise/api/v1/accounts#limits", Source: "routes.rb:525"}, + {Method: "POST", Path: "/enterprise/api/v1/accounts/:account_id/toggle_deletion", Controller: "enterprise/api/v1/accounts#toggle_deletion", Source: "routes.rb:526"}, + {Method: "POST", Path: "/enterprise/api/v1/accounts/:account_id/topup_checkout", Controller: "enterprise/api/v1/accounts#topup_checkout", Source: "routes.rb:527"}, {Method: "POST", Path: "/api/v1/accounts/", Controller: "api/v1/accounts#create", Source: "routes.rb:47"}, {Method: "GET", Path: "/api/v1/accounts/:account_id", Controller: "api/v1/accounts#show", Source: "routes.rb:47"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 2a6433d4..df21aab0 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,16 +49,16 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P6.1 webhook placeholder fallback burn-down, prepared as `fix(webhooks): replace parity stubs`. -- Latest implementation checkpoint: this checkpoint, prepared as `fix(webhooks): replace parity stubs`. -- Latest documentation/tooling checkpoint: this tracker update records removal of `chatwootParityStub`, explicit webhook nil-handler fallback behavior, and the refreshed Phase 6 placeholder audit. +- Current tracking checkpoint: 2026-06-07 P3.102 enterprise account limits and billing-route parity, prepared as `feat(enterprise): align account limits API`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(enterprise): align account limits API`. +- Latest documentation/tooling checkpoint: this tracker update records the enterprise account API route expansion, Chatwoot account-limit payload shape, deletion toggle side effects, and regenerated route parity artifacts. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: the remaining `chatwootParityStub` webhook nil-handler fallbacks are replaced with explicit `503 webhook provider unavailable` responses, and the unused placeholder helpers are removed. This keeps real provider handlers untouched when wired, prevents any registered route from returning a placeholder/not-implemented body when a handler is missing, and refreshes the Phase 6 placeholder audit. This retains P3.101 WhatsApp call route-parameter parity, P3.100 dashboard app route-parameter parity, P3.99 conversation destroy async parity, P3.98 conversation transcript delivery parity, P3.97 conversation typing event parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: the reused dashboard enterprise account client routes from `reference/chatwoot/app/javascript/dashboard/api/enterprise/account.js` and `routes.rb:523-527` are registered under `/enterprise/api/v1/accounts/:account_id/*`. `limits` returns Chatwoot-shaped `{ id, limits }` usage data, `toggle_deletion` writes/removes Chatwoot deletion custom attributes, `subscription` records the local customer-creation guard, and checkout/top-up endpoints return explicit billing-provider errors instead of missing routes. This retains P6.1 webhook placeholder burn-down, P3.101 WhatsApp call route-parameter parity, P3.100 dashboard app route-parameter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. -- Route dump succeeds with `967` registered routes after WhatsApp call route-parameter tracking. +- Route dump succeeds with `972` registered routes after enterprise account route tracking. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 439 Chatwoot routes: 430 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. +- Tracked frontend-critical route audit covers 444 Chatwoot routes: 435 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/api/v1/widget` stubs are burned down, public inbox/contact/conversation/message core flows are backed by real handlers, and `chatwootParityStub` no longer exists in product code. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.102 enterprise account limits and billing routes | Implemented for reused enterprise account frontend calls: `GET/POST /enterprise/api/v1/accounts/:account_id/{limits,checkout,subscription,toggle_deletion,topup_checkout}` are now registered and tracked from `routes.rb:523-527`; `limits` returns Chatwoot-shaped usage data for agents, Captain documents/responses, and default-plan conversation/non-web-inbox counts; `toggle_deletion` mutates `marked_for_deletion_at` and `marked_for_deletion_reason`; `subscription` persists the `is_creating_customer` guard when no Stripe customer exists; checkout/top-up return explicit billing-provider errors instead of missing routes. | Keep in Review; reopen from B12 enterprise account smoke or fresh reference evidence for actual Stripe session creation, cloud-env gating, plan-config defaults, or account deletion notification/cancellation jobs beyond the local persisted boundary. | Focused EnterpriseAccountHandler tests passed; route dump/parity artifacts regenerated to `TOTAL: 972` and `435 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 444`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. | | 0 | P6.1 webhook placeholder fallback burn-down | Implemented for Phase 6 placeholder cleanup: `chatwootParityStub` and the unused webhook placeholder helper are removed from product code. Public webhook nil-handler guards now return explicit `503 { error: "webhook provider unavailable", message: "webhook handler is not configured" }` responses instead of `501 not implemented` placeholder bodies, while wired provider handlers still own real Telegram, WhatsApp, TikTok, LINE, Twilio, Twitter, Instagram, and Shopify behavior. | Keep in Review; reopen from placeholder audit or webhook smoke if a frontend/provider-reachable route returns placeholder/not-implemented content or if a provider handler is missing from normal bootstrap. | Focused router tests cover route boot and nil-handler fallback body/status; placeholder audit refreshed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | | 0 | P3.101 WhatsApp call route parameter parity | Implemented for reused WhatsApp call controls: account-level show/accept/reject/terminate/upload-recording routes now register Chatwoot's `:id` parameter name from `routes.rb:237-242`, while the handler still accepts legacy `:call_id` mounts. Route parity improves from `425 exact / 14 parameter-compatible` to `430 exact / 9 parameter-compatible` with no missing critical routes. AgentCapacity nested route names stay parameter-compatible in the main Gin router because Gin cannot register `/:id` policy members and `/:agent_capacity_policy_id` nested siblings under the same prefix without a wildcard conflict; the nested handlers now tolerate both parameter-name families. | Keep in Review; reopen from B12 WhatsApp calling smoke or fresh reference evidence for route/action payload drift beyond the inspected enterprise controller/frontend call API contract. | Focused WhatsAppCallHandler, AgentCapacityHandler, and router tests passed; route dump/parity artifacts regenerated; full `go test ./...` passed outside the restricted socket sandbox after retrying the known `internal/worker` SQLite in-memory flake; `git diff --check` passed. | | 0 | P3.100 dashboard app route parameter parity | Implemented for reused dashboard app settings routes: GoChat now registers standard dashboard app member routes with Chatwoot's `:id` parameter name for `show/update/destroy`, keeps `PATCH` and `PUT`, and preserves legacy handler compatibility for local focused tests and widget extension routes. Route parity improves from `421 exact / 18 parameter-compatible` to `425 exact / 14 parameter-compatible` with no missing critical routes. | Keep in Review; reopen from B12 dashboard app settings smoke or fresh reference evidence for serializer/request-permit drift beyond the inspected controller/Jbuilder/frontend store contract. | Focused DashboardAppHandler and router tests passed; route dump/parity artifacts regenerated; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. | @@ -2786,3 +2787,4 @@ Verification milestone gates: - 2026-06-07: P3.100 dashboard app route-parameter checkpoint prepared as `fix(routes): align dashboard app ids`; audited Chatwoot `routes.rb:130`, `DashboardAppsController`, dashboard app Jbuilder views, and reused dashboard `dashboardApps.js` API/store calls. GoChat now registers dashboard app member CRUD routes with Chatwoot's `:id` parameter name, keeps `PATCH` and `PUT`, and preserves legacy local `:dashboard_app_id` parsing for focused tests and GoChat-only widget extensions. Focused DashboardAppHandler and router tests passed; route dump/parity regenerated to `TOTAL: 967` and `425 exact, 0 method-compatible, 14 parameter-compatible, 0 missing out of 439`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. - 2026-06-07: P3.101 WhatsApp call route-parameter checkpoint prepared as `fix(routes): align whatsapp call ids`; audited Chatwoot `routes.rb:237-242` and the reused account WhatsApp call route family. GoChat now registers account-level WhatsApp call show/action routes with Chatwoot's `:id` parameter name, preserves legacy `:call_id` handler compatibility, and makes AgentCapacity nested handlers accept both Chatwoot `:agent_capacity_policy_id`/`:id` and local Gin-compatible `:id`/`:user_id`/`:limit_id` parameter names. The main router keeps AgentCapacity nested route names parameter-compatible because Gin rejects wildcard-name changes below the existing policy `/:id` member route. Focused WhatsAppCallHandler, AgentCapacityHandler, and router tests passed; route dump/parity regenerated to `TOTAL: 967` and `430 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 439`; full `go test ./...` passed outside the restricted socket sandbox after retrying the known `internal/worker` SQLite in-memory flake; `git diff --check` passed. - 2026-06-07: P6.1 webhook placeholder fallback checkpoint prepared as `fix(webhooks): replace parity stubs`; refreshed the Phase 6 placeholder audit and burned down the remaining `chatwootParityStub` nil-handler fallbacks in `internal/router/router.go`. Public webhook routes now return explicit `503 webhook provider unavailable` JSON when a provider handler is not configured instead of `501 not implemented` placeholder bodies, and the unused placeholder helper is removed. Focused router tests cover boot and nil-handler fallback behavior; placeholder audit shows no `chatwootParityStub` product-code matches; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.102 enterprise account limits checkpoint prepared as `feat(enterprise): align account limits API`; audited Chatwoot enterprise `AccountsController#limits/#toggle_deletion/#subscription/#checkout/#topup_checkout`, `BillingHelper`, `Enterprise::Account::PlanUsageAndLimits`, reused dashboard `api/enterprise/account.js`, and `routes.rb:523-527`. GoChat now registers the enterprise account route family under `/enterprise/api/v1/accounts/:account_id`, returns Chatwoot-shaped account limit payloads, persists scheduled deletion custom attributes, records subscription customer-creation guards, and exposes explicit local billing-provider errors for checkout/top-up paths. Focused EnterpriseAccountHandler tests passed; route dump/parity regenerated to `TOTAL: 972` and `435 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 444`; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 572ef9e0..458a9ce0 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -444,6 +444,7 @@ GET /app GET /app/*params GET /auth/validate_token GET /cable +GET /enterprise/api/v1/accounts/:account_id/limits GET /google/callback GET /hc/:slug GET /hc/:slug/:locale @@ -834,6 +835,10 @@ POST /api/v2/accounts/ POST /auth/confirmation POST /auth/password POST /auth/sign_in +POST /enterprise/api/v1/accounts/:account_id/checkout +POST /enterprise/api/v1/accounts/:account_id/subscription +POST /enterprise/api/v1/accounts/:account_id/toggle_deletion +POST /enterprise/api/v1/accounts/:account_id/topup_checkout POST /platform/api/v1/accounts POST /platform/api/v1/accounts/:account_id/account_users POST /platform/api/v1/agent_bots @@ -965,4 +970,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 967 +TOTAL: 972 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 73bf78dd..31e01c91 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 430 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 439 tracked critical routes. +Summary: 435 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 444 tracked critical routes. ## Missing Critical Routes @@ -243,6 +243,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v2/accounts/:account_id/year_in_review` | `/api/v2/accounts/:account_id/year_in_review` | `api/v2/accounts/year_in_reviews#show` | `routes.rb:505` | exact | | GET | `/app` | `/app` | `dashboard#index` | `routes.rb:19` | exact | | GET | `/app/*params` | `/app/*params` | `dashboard#index` | `routes.rb:20` | exact | +| GET | `/enterprise/api/v1/accounts/:account_id/limits` | `/enterprise/api/v1/accounts/:account_id/limits` | `enterprise/api/v1/accounts#limits` | `routes.rb:525` | exact | | GET | `/google/callback` | `/google/callback` | `google/callbacks#show` | `routes.rb:650` | exact | | GET | `/hc/:slug` | `/hc/:slug` | `public/api/v1/portals#show` | `routes.rb:590` | exact | | GET | `/hc/:slug/:locale` | `/hc/:slug/:locale` | `public/api/v1/portals#show` | `routes.rb:592` | exact | @@ -420,6 +421,10 @@ These routes exist with equivalent method and path shape but different parameter | POST | `/api/v1/widget/labels` | `/api/v1/widget/labels` | `api/v1/widget/labels#create` | `routes.rb:464` | exact | | POST | `/api/v1/widget/messages` | `/api/v1/widget/messages` | `api/v1/widget/messages#create` | `routes.rb:447` | exact | | POST | `/api/v2/accounts/` | `/api/v2/accounts/` | `api/v2/accounts#create` | `routes.rb:478` | exact | +| POST | `/enterprise/api/v1/accounts/:account_id/checkout` | `/enterprise/api/v1/accounts/:account_id/checkout` | `enterprise/api/v1/accounts#checkout` | `routes.rb:523` | exact | +| POST | `/enterprise/api/v1/accounts/:account_id/subscription` | `/enterprise/api/v1/accounts/:account_id/subscription` | `enterprise/api/v1/accounts#subscription` | `routes.rb:524` | exact | +| POST | `/enterprise/api/v1/accounts/:account_id/toggle_deletion` | `/enterprise/api/v1/accounts/:account_id/toggle_deletion` | `enterprise/api/v1/accounts#toggle_deletion` | `routes.rb:526` | exact | +| POST | `/enterprise/api/v1/accounts/:account_id/topup_checkout` | `/enterprise/api/v1/accounts/:account_id/topup_checkout` | `enterprise/api/v1/accounts#topup_checkout` | `routes.rb:527` | exact | | POST | `/public/api/v1/inboxes/:inbox_id/contacts` | `/public/api/v1/inboxes/:inbox_id/contacts` | `public/api/v1/inboxes/contacts#create` | `routes.rb:572` | exact | | POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations` | `public/api/v1/inboxes/conversations#create` | `routes.rb:573` | exact | | POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `public/api/v1/inboxes/messages#create` | `routes.rb:580` | exact | diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index a0b63fb4..d948a918 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -739,6 +739,7 @@ func Bootstrap(env string) (*App, error) { MFA: v1.NewMFAHandler(mfaService), SAML: v1.NewSAMLHandler(samlService, jwtService, refreshStore, ssoSessionStore, &cfg.SAML), Account: v1.NewAccountHandler(accountService), + EnterpriseAccount: v1.NewEnterpriseAccountHandler(accountService), Contact: v1.NewContactHandler(contactService, contactInboxService, contactMergeService, contactNoteService, conversationService), Conversation: v1.NewConversationHandler(conversationService, messageService).WithAuditService(auditService), Inbox: v1.NewInboxHandler(inboxService).WithAuditService(auditService), diff --git a/internal/handler/api/v1/enterprise_account_handler.go b/internal/handler/api/v1/enterprise_account_handler.go new file mode 100644 index 00000000..072b87cc --- /dev/null +++ b/internal/handler/api/v1/enterprise_account_handler.go @@ -0,0 +1,122 @@ +package v1 + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/service" + "github.com/gochat/gochat/pkg/response" +) + +// EnterpriseAccountHandler implements Chatwoot enterprise account billing and +// limit endpoints consumed by the reused dashboard EnterpriseAccountAPI client. +type EnterpriseAccountHandler struct { + svc *service.AccountService +} + +func NewEnterpriseAccountHandler(svc *service.AccountService) *EnterpriseAccountHandler { + return &EnterpriseAccountHandler{svc: svc} +} + +// Limits returns account usage limits in Chatwoot's enterprise payload shape. +// GET /enterprise/api/v1/accounts/:account_id/limits +func (h *EnterpriseAccountHandler) Limits(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + + payload, err := h.svc.EnterpriseLimits(c.Request.Context(), accountID, getUserID(c)) + if err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") + return + } + c.JSON(http.StatusOK, payload) +} + +// ToggleDeletion marks or unmarks an account for scheduled deletion. +// POST /enterprise/api/v1/accounts/:account_id/toggle_deletion +func (h *EnterpriseAccountHandler) ToggleDeletion(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + + var req struct { + ActionType string `json:"action_type" form:"action_type"` + } + _ = c.ShouldBind(&req) + + switch req.ActionType { + case "delete": + if _, err := h.svc.MarkForDeletion(c.Request.Context(), accountID, getUserID(c), "manual_deletion"); err != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Account marked for deletion"}) + case "undelete": + if _, err := h.svc.UnmarkForDeletion(c.Request.Context(), accountID, getUserID(c)); err != nil { + response.AbortWithStatusError(c, http.StatusUnprocessableEntity, response.ErrValidation, err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Account unmarked for deletion"}) + default: + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Invalid action_type. Must be either \"delete\" or \"undelete\""}) + } +} + +// Subscription mirrors the Cloud customer-creation guard and returns no content. +// POST /enterprise/api/v1/accounts/:account_id/subscription +func (h *EnterpriseAccountHandler) Subscription(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + if err := h.svc.EnsureEnterpriseAccountCustomerCreationFlag(c.Request.Context(), accountID, getUserID(c)); err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") + return + } + c.Status(http.StatusNoContent) +} + +// Checkout returns Chatwoot's billing-details error when no Stripe session can be created locally. +// POST /enterprise/api/v1/accounts/:account_id/checkout +func (h *EnterpriseAccountHandler) Checkout(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + if _, err := h.svc.GetByUserAndID(c.Request.Context(), getUserID(c), accountID); err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") + return + } + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Please subscribe to a plan before viewing the billing details"}) +} + +// TopupCheckout validates credits and exposes a provider-unavailable boundary for local installs. +// POST /enterprise/api/v1/accounts/:account_id/topup_checkout +func (h *EnterpriseAccountHandler) TopupCheckout(c *gin.Context) { + accountID := parseAccountIDParam(c) + if accountID == 0 { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id") + return + } + if _, err := h.svc.GetByUserAndID(c.Request.Context(), getUserID(c), accountID); err != nil { + response.AbortWithStatusError(c, http.StatusNotFound, response.ErrAccountNotFound, "account not found") + return + } + + var req struct { + Credits int `json:"credits" form:"credits"` + } + _ = c.ShouldBind(&req) + if req.Credits <= 0 { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Credits are required"}) + return + } + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Top-up checkout provider is not configured"}) +} diff --git a/internal/handler/api/v1/enterprise_account_handler_test.go b/internal/handler/api/v1/enterprise_account_handler_test.go new file mode 100644 index 00000000..38e6ba6f --- /dev/null +++ b/internal/handler/api/v1/enterprise_account_handler_test.go @@ -0,0 +1,174 @@ +package v1 + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/service" +) + +func TestEnterpriseAccountLimits_ChatwootPayload(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + + account.AgentLimit = 3 + account.InboxLimit = 4 + account.Limits = datatypes.JSON(`{"captain_documents":5,"captain_responses":7}`) + require.NoError(t, account.SetCustomAttributesMap(map[string]any{"captain_responses_usage": 2})) + require.NoError(t, db.Save(account).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: seedEnterpriseUser(t, db, "agent@example.com").ID, Role: "agent"}).Error) + require.NoError(t, db.Create(&model.CaptainDocument{AccountID: account.ID, AssistantID: 1, Name: "Doc", ExternalLink: "https://example.com"}).Error) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/enterprise/api/v1/accounts/%d/limits", account.ID), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.Equal(t, float64(account.ID), body["id"]) + + limits := body["limits"].(map[string]any) + agents := limits["agents"].(map[string]any) + require.Equal(t, float64(3), agents["allowed"]) + require.Equal(t, float64(2), agents["consumed"]) + + captain := limits["captain"].(map[string]any) + documents := captain["documents"].(map[string]any) + require.Equal(t, float64(5), documents["total_count"]) + require.Equal(t, float64(4), documents["current_available"]) + require.Equal(t, float64(1), documents["consumed"]) + responses := captain["responses"].(map[string]any) + require.Equal(t, float64(7), responses["total_count"]) + require.Equal(t, float64(5), responses["current_available"]) + require.Equal(t, float64(2), responses["consumed"]) +} + +func TestEnterpriseAccountLimits_DefaultPlanPayload(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + require.NoError(t, account.SetCustomAttributesMap(map[string]any{"default_plan": true})) + require.NoError(t, db.Save(account).Error) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + old := time.Now().AddDate(0, 0, -31) + require.NoError(t, db.Create(&model.Conversation{AccountID: account.ID, InboxID: 1, ContactID: 1, ChannelType: "web_widget", Channel: "web_widget"}).Error) + oldConversation := &model.Conversation{AccountID: account.ID, InboxID: 1, ContactID: 1, ChannelType: "web_widget", Channel: "web_widget"} + oldConversation.CreatedAt = old + require.NoError(t, db.Create(oldConversation).Error) + require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Web", ChannelType: "web_widget", ChannelID: 1}).Error) + require.NoError(t, db.Create(&model.Inbox{AccountID: account.ID, Name: "Email", ChannelType: "email", ChannelID: 2}).Error) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/enterprise/api/v1/accounts/%d/limits", account.ID), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + var body map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + limits := body["limits"].(map[string]any) + conversation := limits["conversation"].(map[string]any) + require.Equal(t, float64(500), conversation["allowed"]) + require.Equal(t, float64(1), conversation["consumed"]) + nonWeb := limits["non_web_inboxes"].(map[string]any) + require.Equal(t, float64(0), nonWeb["allowed"]) + require.Equal(t, float64(1), nonWeb["consumed"]) +} + +func TestEnterpriseAccountToggleDeletion(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + + w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "toggle_deletion", `{"action_type":"delete"}`) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.NoError(t, db.First(account, account.ID).Error) + attrs := account.CustomAttributesMap() + require.Equal(t, "manual_deletion", attrs["marked_for_deletion_reason"]) + require.NotEmpty(t, attrs["marked_for_deletion_at"]) + + w = enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "toggle_deletion", `{"action_type":"undelete"}`) + require.Equal(t, http.StatusOK, w.Code, w.Body.String()) + require.NoError(t, db.First(account, account.ID).Error) + attrs = account.CustomAttributesMap() + require.NotContains(t, attrs, "marked_for_deletion_reason") + require.NotContains(t, attrs, "marked_for_deletion_at") +} + +func TestEnterpriseAccountSubscriptionSetsCreationFlag(t *testing.T) { + router, db, account, user := setupEnterpriseAccountHandlerTest(t) + require.NoError(t, db.Create(&model.AccountUser{AccountID: account.ID, UserID: user.ID, Role: "administrator"}).Error) + + w := enterpriseAccountRequest(t, router, account.ID, http.MethodPost, "subscription", ``) + require.Equal(t, http.StatusNoContent, w.Code, w.Body.String()) + require.NoError(t, db.First(account, account.ID).Error) + require.Equal(t, true, account.CustomAttributesMap()["is_creating_customer"]) +} + +func TestEnterpriseAccountRejectsAccountOutsideCurrentUser(t *testing.T) { + router, _, account, _ := setupEnterpriseAccountHandlerTest(t) + + req := httptest.NewRequest(http.MethodGet, fmt.Sprintf("/enterprise/api/v1/accounts/%d/limits", account.ID), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code, w.Body.String()) +} + +func setupEnterpriseAccountHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.User) { + t.Helper() + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + t.Cleanup(func() { + sqlDB, _ := db.DB() + _ = sqlDB.Close() + }) + require.NoError(t, db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Conversation{}, &model.Inbox{}, &model.CaptainDocument{})) + + user := seedEnterpriseUser(t, db, "admin@example.com") + account := &model.Account{Name: "Acme", Active: true, Status: "active", Locale: "en"} + require.NoError(t, db.Create(account).Error) + + svc := service.NewAccountService(repository.NewAccountRepo(db)) + handler := NewEnterpriseAccountHandler(svc) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("user_id", user.ID) + c.Next() + }) + accounts := router.Group("/enterprise/api/v1/accounts") + accounts.GET("/:account_id/limits", handler.Limits) + accounts.POST("/:account_id/toggle_deletion", handler.ToggleDeletion) + accounts.POST("/:account_id/subscription", handler.Subscription) + accounts.POST("/:account_id/checkout", handler.Checkout) + accounts.POST("/:account_id/topup_checkout", handler.TopupCheckout) + return router, db, account, user +} + +func seedEnterpriseUser(t *testing.T, db *gorm.DB, email string) *model.User { + t.Helper() + user := &model.User{Name: email, Email: email, Password: "hashed", Active: true} + require.NoError(t, db.Create(user).Error) + return user +} + +func enterpriseAccountRequest(t *testing.T, router *gin.Engine, accountID uint, method, action, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, fmt.Sprintf("/enterprise/api/v1/accounts/%d/%s", accountID, action), bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + return w +} diff --git a/internal/repository/account_repo.go b/internal/repository/account_repo.go index e8547404..93e4eda6 100644 --- a/internal/repository/account_repo.go +++ b/internal/repository/account_repo.go @@ -15,6 +15,55 @@ type AccountRepo struct { db *gorm.DB } +// FindByUserAndID retrieves an account only when the user belongs to it. +func (r *AccountRepo) FindByUserAndID(ctx context.Context, userID, accountID uint) (*model.Account, error) { + var account model.Account + err := r.db.WithContext(ctx).Model(&model.Account{}). + Joins("JOIN account_users ON account_users.account_id = accounts.id"). + Where("accounts.id = ? AND account_users.user_id = ?", accountID, userID). + First(&account).Error + if err != nil { + return nil, err + } + return &account, nil +} + +// CountConversationsSince counts account conversations created after a timestamp. +func (r *AccountRepo) CountConversationsSince(ctx context.Context, accountID uint, since time.Time) (int64, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&model.Conversation{}). + Where("account_id = ? AND created_at > ?", accountID, since). + Count(&total).Error + return total, err +} + +// CountNonWebInboxes counts account inboxes excluding Chatwoot web widget channels. +func (r *AccountRepo) CountNonWebInboxes(ctx context.Context, accountID uint) (int64, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&model.Inbox{}). + Where("account_id = ? AND channel_type NOT IN ?", accountID, []string{"web_widget", "Channel::WebWidget"}). + Count(&total).Error + return total, err +} + +// CountUsersByAccount counts account users through the account_users join table. +func (r *AccountRepo) CountUsersByAccount(ctx context.Context, accountID uint) (int64, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&model.AccountUser{}). + Where("account_id = ?", accountID). + Count(&total).Error + return total, err +} + +// CountCaptainDocumentsByAccount counts Captain knowledge documents for account usage limits. +func (r *AccountRepo) CountCaptainDocumentsByAccount(ctx context.Context, accountID uint) (int64, error) { + var total int64 + err := r.db.WithContext(ctx).Model(&model.CaptainDocument{}). + Where("account_id = ?", accountID). + Count(&total).Error + return total, err +} + // NewAccountRepo creates a new Account repository. func NewAccountRepo(db *gorm.DB) *AccountRepo { return &AccountRepo{db: db} diff --git a/internal/router/router.go b/internal/router/router.go index 3c5899f1..a9a48b41 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -42,6 +42,7 @@ type Handlers struct { MFA *v1.MFAHandler SAML *v1.SAMLHandler Account *v1.AccountHandler + EnterpriseAccount *v1.EnterpriseAccountHandler Contact *v1.ContactHandler Conversation *v1.ConversationHandler Inbox *v1.InboxHandler @@ -248,6 +249,12 @@ func RegisterRoutes( apiV1.Use(middleware.AuthMiddleware(jwtCfg)) registerV1Routes(apiV1, handlers) + // Enterprise API routes consumed by the reused Chatwoot dashboard. + // Reference: Chatwoot routes.rb namespace :enterprise/:api/:v1. + enterpriseV1 := engine.Group("/enterprise/api/v1") + enterpriseV1.Use(middleware.AuthMiddleware(jwtCfg)) + registerEnterpriseRoutes(enterpriseV1, handlers) + // Platform API routes — super admin only (ref: Chatwoot namespace :platform_app) // Chatwoot uses a single /platform/api/v1 prefix with AccessTokenable concern in controller layer // for auth differentiation. We merge SuperAdmin and AccessToken routes into one group. @@ -564,6 +571,21 @@ func RegisterRoutes( engine.GET("/cable", wsHandler.ServeCable) // ActionCable-compatible endpoint (Chatwoot convention) } +func registerEnterpriseRoutes(g *gin.RouterGroup, h *Handlers) { + if h == nil || h.EnterpriseAccount == nil { + return + } + + accounts := g.Group("/accounts") + { + accounts.POST("/:account_id/checkout", h.EnterpriseAccount.Checkout) + accounts.POST("/:account_id/subscription", h.EnterpriseAccount.Subscription) + accounts.GET("/:account_id/limits", h.EnterpriseAccount.Limits) + accounts.POST("/:account_id/toggle_deletion", h.EnterpriseAccount.ToggleDeletion) + accounts.POST("/:account_id/topup_checkout", h.EnterpriseAccount.TopupCheckout) + } +} + // registerV1Routes maps all API v1 resource routes. // Reference: Chatwoot routes.rb namespace :api, scope :v1 func registerV1Routes(g *gin.RouterGroup, h *Handlers) { diff --git a/internal/service/account_service.go b/internal/service/account_service.go index b3348a49..9bb663da 100644 --- a/internal/service/account_service.go +++ b/internal/service/account_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "time" @@ -18,6 +19,8 @@ type AccountService struct { repo *repository.AccountRepo } +const chatwootMaxLimit = 100000 + // NewAccountService creates a new Account service. func NewAccountService(repo *repository.AccountRepo) *AccountService { return &AccountService{repo: repo} @@ -33,6 +36,11 @@ func (s *AccountService) GetByID(ctx context.Context, id uint) (*model.Account, return s.repo.FindByID(ctx, id) } +// GetByUserAndID retrieves an account only when the user belongs to it. +func (s *AccountService) GetByUserAndID(ctx context.Context, userID, accountID uint) (*model.Account, error) { + return s.repo.FindByUserAndID(ctx, userID, accountID) +} + // CreateAccountRequest is the DTO for creating an account. type CreateAccountRequest struct { Name string `json:"name,omitempty" validate:"omitempty,min=2"` @@ -293,3 +301,202 @@ func (s *AccountService) CacheKeys(ctx context.Context, accountID, userID uint) } return keys, nil } + +// EnterpriseLimits returns the Chatwoot enterprise account limit payload consumed +// by EnterpriseAccountAPI.getLimits in the reused dashboard frontend. +func (s *AccountService) EnterpriseLimits(ctx context.Context, accountID, userID uint) (map[string]any, error) { + account, err := s.repo.FindByUserAndID(ctx, userID, accountID) + if err != nil { + return nil, err + } + + agentsConsumed, err := s.repo.CountUsersByAccount(ctx, account.ID) + if err != nil { + return nil, err + } + documentsConsumed, err := s.repo.CountCaptainDocumentsByAccount(ctx, account.ID) + if err != nil { + return nil, err + } + + limits := map[string]any{ + "conversation": map[string]any{}, + "non_web_inboxes": map[string]any{}, + "agents": map[string]any{ + "allowed": accountUsageLimit(account.AgentLimit), + "consumed": agentsConsumed, + }, + "captain": captainUsageLimits(account, documentsConsumed), + } + + if accountDefaultCloudPlan(account) { + conversationConsumed, err := s.repo.CountConversationsSince(ctx, account.ID, time.Now().AddDate(0, 0, -30)) + if err != nil { + return nil, err + } + nonWebConsumed, err := s.repo.CountNonWebInboxes(ctx, account.ID) + if err != nil { + return nil, err + } + limits = map[string]any{ + "conversation": map[string]any{ + "allowed": 500, + "consumed": conversationConsumed, + }, + "non_web_inboxes": map[string]any{ + "allowed": 0, + "consumed": nonWebConsumed, + }, + "agents": map[string]any{ + "allowed": 2, + "consumed": agentsConsumed, + }, + } + } + + return map[string]any{"id": account.ID, "limits": limits}, nil +} + +// MarkForDeletion mirrors Enterprise::Account#mark_for_deletion by storing the +// scheduled deletion timestamp and reason in account custom_attributes. +func (s *AccountService) MarkForDeletion(ctx context.Context, accountID, userID uint, reason string) (*model.Account, error) { + account, err := s.repo.FindByUserAndID(ctx, userID, accountID) + if err != nil { + return nil, err + } + attrs := account.CustomAttributesMap() + if reason != "manual_deletion" { + reason = "inactivity" + } + attrs["marked_for_deletion_at"] = time.Now().AddDate(0, 0, 7).Format(time.RFC3339) + attrs["marked_for_deletion_reason"] = reason + if err := account.SetCustomAttributesMap(attrs); err != nil { + return nil, err + } + if err := s.repo.Update(ctx, account); err != nil { + return nil, err + } + return account, nil +} + +// UnmarkForDeletion removes Chatwoot's scheduled deletion custom attributes. +func (s *AccountService) UnmarkForDeletion(ctx context.Context, accountID, userID uint) (*model.Account, error) { + account, err := s.repo.FindByUserAndID(ctx, userID, accountID) + if err != nil { + return nil, err + } + attrs := account.CustomAttributesMap() + delete(attrs, "marked_for_deletion_at") + delete(attrs, "marked_for_deletion_reason") + if err := account.SetCustomAttributesMap(attrs); err != nil { + return nil, err + } + if err := s.repo.Update(ctx, account); err != nil { + return nil, err + } + return account, nil +} + +// EnsureEnterpriseAccountCustomerCreationFlag tracks the Cloud subscription side +// effect that prevents duplicate Stripe customer creation jobs in Chatwoot. +func (s *AccountService) EnsureEnterpriseAccountCustomerCreationFlag(ctx context.Context, accountID, userID uint) error { + account, err := s.repo.FindByUserAndID(ctx, userID, accountID) + if err != nil { + return err + } + attrs := account.CustomAttributesMap() + if attrs["stripe_customer_id"] != nil || attrs["is_creating_customer"] != nil { + return nil + } + attrs["is_creating_customer"] = true + if err := account.SetCustomAttributesMap(attrs); err != nil { + return err + } + return s.repo.Update(ctx, account) +} + +func accountUsageLimit(limit int) int { + if limit > 0 { + return limit + } + return chatwootMaxLimit +} + +func accountJSONMap(raw []byte) map[string]any { + out := map[string]any{} + if len(raw) > 0 { + _ = json.Unmarshal(raw, &out) + } + return out +} + +func accountDefaultCloudPlan(account *model.Account) bool { + attrs := account.CustomAttributesMap() + if value, ok := attrs["default_plan"]; ok { + if enabled, ok := value.(bool); ok { + return enabled + } + } + return false +} + +func captainUsageLimits(account *model.Account, documentsConsumed int64) map[string]any { + limits := accountJSONMap(account.Limits) + attrs := account.CustomAttributesMap() + documentsAllowed := intFromAccountMap(limits, "captain_documents", chatwootMaxLimit) + responsesAllowed := intFromAccountMap(limits, "captain_responses", chatwootMaxLimit) + responsesConsumed := intFromAccountMap(attrs, "captain_responses_usage", 0) + if docsAttr, ok := optionalIntFromAccountMap(attrs, "captain_documents_usage"); ok { + documentsConsumed = int64(docsAttr) + } + + return map[string]any{ + "documents": captainLimitBlock(documentsAllowed, int(documentsConsumed)), + "responses": captainLimitBlock(responsesAllowed, responsesConsumed), + } +} + +func captainLimitBlock(total, consumed int) map[string]any { + if consumed < 0 { + consumed = 0 + } + available := total - consumed + if available < 0 { + available = 0 + } + if available > total { + available = total + } + return map[string]any{"total_count": total, "current_available": available, "consumed": consumed} +} + +func intFromAccountMap(values map[string]any, key string, fallback int) int { + if value, ok := optionalIntFromAccountMap(values, key); ok { + return value + } + return fallback +} + +func optionalIntFromAccountMap(values map[string]any, key string) (int, bool) { + value, ok := values[key] + if !ok || value == nil { + return 0, false + } + switch v := value.(type) { + case int: + return v, true + case int64: + return int(v), true + case float64: + return int(v), true + case json.Number: + n, err := v.Int64() + return int(n), err == nil + case string: + var n int + if _, err := fmt.Sscanf(v, "%d", &n); err == nil { + return n, true + } + } + return 0, false +}