From 5c3bc4a49a3269ae0ae0412dd63a452cb322a050 Mon Sep 17 00:00:00 2001 From: IT22056320 Date: Sun, 5 Apr 2026 09:21:57 +0530 Subject: [PATCH] feat: Implement subscription management and request limits --- .../org/springforge/auth/SessionManager.kt | 6 + .../subscription/SubscriptionManager.kt | 135 ++++++++ .../subscription/SubscriptionStatus.kt | 15 + .../subscription/SubscriptionTier.kt | 9 + .../subscription/ui/PricingDialog.kt | 291 ++++++++++++++++++ .../subscription/ui/RequestLimitDialog.kt | 128 ++++++++ .../subscription/ui/SubscriptionPanel.kt | 271 ++++++++++++++++ .../toolwindow/SpringForgeToolWindowPanel.kt | 61 +++- .../toolwindow/panels/CICDPanel.kt | 15 + .../toolwindow/panels/CodeGenerationPanel.kt | 14 + .../panels/QualityAssurancePanel.kt | 13 + .../toolwindow/panels/RuntimeAnalysisPanel.kt | 13 + 12 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/springforge/subscription/SubscriptionManager.kt create mode 100644 src/main/java/org/springforge/subscription/SubscriptionStatus.kt create mode 100644 src/main/java/org/springforge/subscription/SubscriptionTier.kt create mode 100644 src/main/java/org/springforge/subscription/ui/PricingDialog.kt create mode 100644 src/main/java/org/springforge/subscription/ui/RequestLimitDialog.kt create mode 100644 src/main/java/org/springforge/subscription/ui/SubscriptionPanel.kt diff --git a/src/main/java/org/springforge/auth/SessionManager.kt b/src/main/java/org/springforge/auth/SessionManager.kt index 96b8e9b..071c6e0 100644 --- a/src/main/java/org/springforge/auth/SessionManager.kt +++ b/src/main/java/org/springforge/auth/SessionManager.kt @@ -1,6 +1,7 @@ package org.springforge.auth import com.intellij.openapi.components.Service +import org.springforge.subscription.SubscriptionManager /** * Application-level singleton that holds the current user session. @@ -23,11 +24,16 @@ class SessionManager { fun login(token: String, user: UserInfo) { this.token = token this.currentUser = user + // Fetch subscription status in background after login + Thread { + SubscriptionManager.getInstance().fetchStatus(token) + }.start() } fun logout() { token = null currentUser = null + SubscriptionManager.getInstance().reset() } companion object { diff --git a/src/main/java/org/springforge/subscription/SubscriptionManager.kt b/src/main/java/org/springforge/subscription/SubscriptionManager.kt new file mode 100644 index 0000000..a44abb2 --- /dev/null +++ b/src/main/java/org/springforge/subscription/SubscriptionManager.kt @@ -0,0 +1,135 @@ +package org.springforge.subscription + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.Logger +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.springforge.cicdassistant.config.EnvironmentConfig +import java.util.concurrent.TimeUnit + +/** + * Application-level singleton managing the current user's subscription status. + * Fetched from backend after login, reset on logout. + */ +@Service(Service.Level.APP) +class SubscriptionManager { + + private val LOG = Logger.getInstance(SubscriptionManager::class.java) + private val mapper = jacksonObjectMapper() + + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + @Volatile + var status: SubscriptionStatus = SubscriptionStatus() + private set + + /** Called on EDT after any status change. Register from the tool window header. */ + var onStatusChanged: (() -> Unit)? = null + + fun canMakeRequest(): Boolean = status.canMakeRequest() + + private fun notifyChanged() { + val cb = onStatusChanged ?: return + javax.swing.SwingUtilities.invokeLater { cb() } + } + + /** + * Fetches subscription status from GET /api/subscription/status. + * Called after login. Falls back to Community defaults on error. + */ + fun fetchStatus(token: String) { + try { + val url = "${baseUrl()}/api/subscription/status" + val request = Request.Builder() + .url(url) + .get() + .header("Authorization", "Bearer $token") + .build() + + client.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) { + LOG.warn("[SpringForge] Subscription status fetch failed: ${resp.code} — defaulting to Community") + return + } + val body = resp.body?.string() ?: return + val parsed = mapper.readTree(body) + val tier = when (parsed["tier"]?.asText()?.uppercase()) { + "ULTIMATE" -> SubscriptionTier.ULTIMATE + else -> SubscriptionTier.COMMUNITY + } + val used = parsed["requestsUsed"]?.asInt() ?: 0 + val limit = if (tier == SubscriptionTier.ULTIMATE) Int.MAX_VALUE + else (parsed["requestsLimit"]?.asInt() ?: 5) + val resetAt = parsed["resetAt"]?.asText() ?: "" + val expiresAt = parsed["expiresAt"]?.asText() ?: "" + status = SubscriptionStatus(tier, used, limit, resetAt, expiresAt) + LOG.info("[SpringForge] Subscription loaded: $tier, $used/${if (limit == Int.MAX_VALUE) "∞" else limit.toString()} requests") + notifyChanged() + } + } catch (e: Exception) { + LOG.warn("[SpringForge] Could not fetch subscription status: ${e.message} — defaulting to Community") + } + } + + /** + * Increments usage count on the backend and updates local count. + * Called after a successful AI request in any panel. + * No-op if ULTIMATE or backend call fails. + */ + fun incrementUsage(token: String) { + if (status.tier == SubscriptionTier.ULTIMATE) return + try { + val url = "${baseUrl()}/api/subscription/usage/increment" + val request = Request.Builder() + .url(url) + .post("{}".toRequestBody("application/json".toMediaType())) + .header("Authorization", "Bearer $token") + .build() + + client.newCall(request).execute().use { resp -> + if (resp.isSuccessful) { + val body = resp.body?.string() + val used = body?.let { mapper.readTree(it)["requestsUsed"]?.asInt() } + ?: (status.requestsUsed + 1) + status = status.copy(requestsUsed = used) + LOG.info("[SpringForge] Usage incremented: ${status.requestsUsed}/${status.requestsLimit}") + notifyChanged() + } + } + } catch (e: Exception) { + // Increment locally even if backend call fails + status = status.copy(requestsUsed = status.requestsUsed + 1) + LOG.warn("[SpringForge] Could not increment usage on backend, incremented locally: ${e.message}") + notifyChanged() + } + } + + /** + * Re-fetches subscription status. Called after user completes Stripe checkout. + */ + fun refreshStatus(token: String) = fetchStatus(token) + + /** + * Resets to Community defaults on logout. + */ + fun reset() { + status = SubscriptionStatus() + LOG.info("[SpringForge] Subscription status reset to Community") + notifyChanged() + } + + private fun baseUrl(): String = EnvironmentConfig.Backend.url.trimEnd('/') + + companion object { + fun getInstance(): SubscriptionManager = + com.intellij.openapi.application.ApplicationManager.getApplication() + .getService(SubscriptionManager::class.java) + } +} diff --git a/src/main/java/org/springforge/subscription/SubscriptionStatus.kt b/src/main/java/org/springforge/subscription/SubscriptionStatus.kt new file mode 100644 index 0000000..ef81bdc --- /dev/null +++ b/src/main/java/org/springforge/subscription/SubscriptionStatus.kt @@ -0,0 +1,15 @@ +package org.springforge.subscription + +data class SubscriptionStatus( + val tier: SubscriptionTier = SubscriptionTier.COMMUNITY, + val requestsUsed: Int = 0, + val requestsLimit: Int = 5, + val resetAt: String = "", + val expiresAt: String = "" // non-blank = cancellation pending, subscription ends on this date +) { + fun canMakeRequest(): Boolean = + tier == SubscriptionTier.ULTIMATE || requestsUsed < requestsLimit + + fun usageDisplay(): String = + if (tier == SubscriptionTier.ULTIMATE) "Unlimited" else "$requestsUsed/$requestsLimit requests" +} diff --git a/src/main/java/org/springforge/subscription/SubscriptionTier.kt b/src/main/java/org/springforge/subscription/SubscriptionTier.kt new file mode 100644 index 0000000..b9d47da --- /dev/null +++ b/src/main/java/org/springforge/subscription/SubscriptionTier.kt @@ -0,0 +1,9 @@ +package org.springforge.subscription + +enum class SubscriptionTier { + COMMUNITY, + ULTIMATE; + + fun isCommunity() = this == COMMUNITY + fun isUltimate() = this == ULTIMATE +} diff --git a/src/main/java/org/springforge/subscription/ui/PricingDialog.kt b/src/main/java/org/springforge/subscription/ui/PricingDialog.kt new file mode 100644 index 0000000..8cc637b --- /dev/null +++ b/src/main/java/org/springforge/subscription/ui/PricingDialog.kt @@ -0,0 +1,291 @@ +package org.springforge.subscription.ui + +import com.intellij.ui.JBColor +import org.springforge.auth.SessionManager +import org.springforge.subscription.SubscriptionManager +import org.springforge.subscription.SubscriptionTier +import java.awt.* +import java.net.URI +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.swing.* +import javax.swing.border.EmptyBorder +import javax.swing.border.LineBorder + +/** + * Full pricing comparison dialog opened from the header Upgrade button. + */ +class PricingDialog : JDialog() { + + init { + title = "SpringForge Plans" + isModal = true + defaultCloseOperation = DISPOSE_ON_CLOSE + contentPane = buildContent() + pack() + setLocationRelativeTo(null) + minimumSize = Dimension(520, 460) + } + + private fun buildContent(): JPanel { + val root = JPanel(BorderLayout(0, 16)) + root.border = EmptyBorder(24, 28, 24, 28) + root.background = UIManager.getColor("Panel.background") + + // Title + val title = JLabel("Choose Your Plan") + title.font = title.font.deriveFont(Font.BOLD, 18f) + title.horizontalAlignment = SwingConstants.CENTER + root.add(title, BorderLayout.NORTH) + + // Plan cards row + val cardsPanel = JPanel(GridLayout(1, 2, 16, 0)) + cardsPanel.isOpaque = false + + val currentTier = SubscriptionManager.getInstance().status.tier + cardsPanel.add(buildCommunityCard(currentTier)) + cardsPanel.add(buildUltimateCard(currentTier)) + + root.add(cardsPanel, BorderLayout.CENTER) + + // Bottom row + val bottomRow = JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)) + bottomRow.isOpaque = false + + val refreshBtn = JButton("Refresh Plan") + refreshBtn.font = refreshBtn.font.deriveFont(11f) + refreshBtn.addActionListener { + val token = SessionManager.getInstance().token ?: return@addActionListener + refreshBtn.isEnabled = false + refreshBtn.text = "Refreshing..." + Thread { + SubscriptionManager.getInstance().refreshStatus(token) + SwingUtilities.invokeLater { dispose() } + }.start() + } + + val closeBtn = JButton("Close") + closeBtn.font = closeBtn.font.deriveFont(11f) + closeBtn.addActionListener { dispose() } + + bottomRow.add(refreshBtn) + bottomRow.add(closeBtn) + root.add(bottomRow, BorderLayout.SOUTH) + + return root + } + + private fun buildCommunityCard(current: SubscriptionTier): JPanel { + val card = JPanel(BorderLayout(0, 12)) + card.border = if (current == SubscriptionTier.COMMUNITY) + LineBorder(Color(80, 140, 255), 2, true) + else + LineBorder(JBColor.border(), 1, true) + card.background = UIManager.getColor("Panel.background") + + val inner = JPanel() + inner.layout = BoxLayout(inner, BoxLayout.Y_AXIS) + inner.isOpaque = false + inner.border = EmptyBorder(16, 16, 16, 16) + + val nameLabel = JLabel("Community") + nameLabel.font = nameLabel.font.deriveFont(Font.BOLD, 14f) + nameLabel.alignmentX = Component.LEFT_ALIGNMENT + + val priceLabel = JLabel("Free") + priceLabel.font = priceLabel.font.deriveFont(Font.BOLD, 22f) + priceLabel.foreground = JBColor.foreground() + priceLabel.alignmentX = Component.LEFT_ALIGNMENT + + val sep = JSeparator() + sep.alignmentX = Component.LEFT_ALIGNMENT + sep.maximumSize = Dimension(Int.MAX_VALUE, 1) + + inner.add(nameLabel) + inner.add(Box.createVerticalStrut(4)) + inner.add(priceLabel) + inner.add(Box.createVerticalStrut(12)) + inner.add(sep) + inner.add(Box.createVerticalStrut(12)) + + listOf( + "✓ All 4 modules — full access", + "✓ CI/CD generation", + "✓ Runtime analysis", + "✓ Code quality analysis", + "✓ Code generation", + "⚠ 5 AI requests/month" + ).forEach { inner.add(featureRow(it)) } + + if (current == SubscriptionTier.COMMUNITY) { + inner.add(Box.createVerticalStrut(12)) + val badge = JLabel("Current Plan") + badge.font = badge.font.deriveFont(Font.BOLD, 10f) + badge.foreground = Color(80, 140, 255) + badge.alignmentX = Component.LEFT_ALIGNMENT + inner.add(badge) + } + + card.add(inner, BorderLayout.CENTER) + return card + } + + private fun buildUltimateCard(current: SubscriptionTier): JPanel { + val card = JPanel(BorderLayout(0, 12)) + card.border = if (current == SubscriptionTier.ULTIMATE) + LineBorder(Color(80, 200, 120), 2, true) + else + LineBorder(JBColor.border(), 1, true) + card.background = UIManager.getColor("Panel.background") + + val inner = JPanel() + inner.layout = BoxLayout(inner, BoxLayout.Y_AXIS) + inner.isOpaque = false + inner.border = EmptyBorder(16, 16, 16, 16) + + val nameLabel = JLabel("Ultimate") + nameLabel.font = nameLabel.font.deriveFont(Font.BOLD, 14f) + nameLabel.alignmentX = Component.LEFT_ALIGNMENT + + val priceRow = JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)) + priceRow.isOpaque = false + priceRow.alignmentX = Component.LEFT_ALIGNMENT + val priceLabel = JLabel("\$9") + priceLabel.font = priceLabel.font.deriveFont(Font.BOLD, 22f) + val perMonth = JLabel("/month") + perMonth.font = perMonth.font.deriveFont(12f) + perMonth.foreground = JBColor.GRAY + priceRow.add(priceLabel) + priceRow.add(perMonth) + + val sep = JSeparator() + sep.alignmentX = Component.LEFT_ALIGNMENT + sep.maximumSize = Dimension(Int.MAX_VALUE, 1) + + inner.add(nameLabel) + inner.add(Box.createVerticalStrut(4)) + inner.add(priceRow) + inner.add(Box.createVerticalStrut(12)) + inner.add(sep) + inner.add(Box.createVerticalStrut(12)) + + listOf( + "✓ All 4 modules — full access", + "✓ CI/CD generation", + "✓ Runtime analysis", + "✓ Code quality analysis", + "✓ Code generation", + "✓ Unlimited AI requests" + ).forEach { inner.add(featureRow(it)) } + + inner.add(Box.createVerticalStrut(12)) + + if (current == SubscriptionTier.ULTIMATE) { + val sub = SubscriptionManager.getInstance().status + val expiresAt = sub.expiresAt + + if (expiresAt.isNotBlank()) { + // Cancellation already scheduled — show pending notice, no cancel button + val cancelNote = JLabel("Cancels on ${formatDate(expiresAt)}") + cancelNote.font = cancelNote.font.deriveFont(Font.BOLD, 10f) + cancelNote.foreground = Color(200, 150, 0) + cancelNote.alignmentX = Component.LEFT_ALIGNMENT + inner.add(cancelNote) + } else { + // Active Ultimate — show Cancel Plan button + val cancelBtn = JButton("Cancel Plan") + cancelBtn.font = cancelBtn.font.deriveFont(11f) + cancelBtn.foreground = Color(200, 60, 60) + cancelBtn.isBorderPainted = true + cancelBtn.isOpaque = false + cancelBtn.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + cancelBtn.alignmentX = Component.LEFT_ALIGNMENT + cancelBtn.maximumSize = Dimension(Int.MAX_VALUE, 32) + cancelBtn.toolTipText = "Manage or cancel your subscription on the website" + cancelBtn.addActionListener { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI("https://www.springforge.dev/pricing")) + } + } + inner.add(cancelBtn) + } + + inner.add(Box.createVerticalStrut(6)) + + // Refresh Plan button — always shown for Ultimate + val refreshBtn = JButton("Refresh Plan") + refreshBtn.font = refreshBtn.font.deriveFont(11f) + refreshBtn.alignmentX = Component.LEFT_ALIGNMENT + refreshBtn.maximumSize = Dimension(Int.MAX_VALUE, 32) + refreshBtn.toolTipText = "Re-check your subscription status from the server" + refreshBtn.addActionListener { + val token = SessionManager.getInstance().token ?: return@addActionListener + refreshBtn.isEnabled = false + refreshBtn.text = "Checking..." + Thread { + SubscriptionManager.getInstance().refreshStatus(token) + SwingUtilities.invokeLater { + dispose() + } + }.start() + } + inner.add(refreshBtn) + + inner.add(Box.createVerticalStrut(6)) + val badge = JLabel("✦ Current Plan") + badge.font = badge.font.deriveFont(Font.BOLD, 10f) + badge.foreground = Color(80, 200, 120) + badge.alignmentX = Component.LEFT_ALIGNMENT + inner.add(badge) + } else { + val upgradeBtn = JButton("Upgrade to Ultimate") + upgradeBtn.font = upgradeBtn.font.deriveFont(Font.BOLD, 11f) + upgradeBtn.foreground = Color.WHITE + upgradeBtn.background = Color(80, 140, 255) + upgradeBtn.isBorderPainted = false + upgradeBtn.isOpaque = true + upgradeBtn.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + upgradeBtn.alignmentX = Component.LEFT_ALIGNMENT + upgradeBtn.maximumSize = Dimension(Int.MAX_VALUE, 32) + upgradeBtn.addActionListener { openCheckout() } + inner.add(upgradeBtn) + } + + card.add(inner, BorderLayout.CENTER) + return card + } + + private fun formatDate(raw: String): String { + return try { + val instant = Instant.parse(raw) + DateTimeFormatter.ofPattern("M/d/yyyy", Locale.ENGLISH) + .withZone(ZoneId.systemDefault()) + .format(instant) + } catch (e: Exception) { raw } + } + + private fun featureRow(text: String): JLabel { + val lbl = JLabel(text) + lbl.font = lbl.font.deriveFont(11f) + lbl.alignmentX = Component.LEFT_ALIGNMENT + lbl.border = EmptyBorder(2, 0, 2, 0) + if (text.startsWith("⚠")) lbl.foreground = Color(180, 120, 0) + return lbl + } + + private fun openCheckout() { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI("https://www.springforge.dev/pricing")) + } + } + + companion object { + fun show() { + SwingUtilities.invokeLater { + PricingDialog().isVisible = true + } + } + } +} diff --git a/src/main/java/org/springforge/subscription/ui/RequestLimitDialog.kt b/src/main/java/org/springforge/subscription/ui/RequestLimitDialog.kt new file mode 100644 index 0000000..b7fe699 --- /dev/null +++ b/src/main/java/org/springforge/subscription/ui/RequestLimitDialog.kt @@ -0,0 +1,128 @@ +package org.springforge.subscription.ui + +import com.intellij.openapi.project.Project +import com.intellij.ui.JBColor +import org.springforge.auth.SessionManager +import org.springforge.subscription.SubscriptionManager +import java.awt.* +import java.net.URI +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.swing.* +import javax.swing.border.EmptyBorder + +/** + * Dialog shown when a Community user has exhausted their 5 requests/month limit. + */ +class RequestLimitDialog(private val project: Project) : JDialog() { + + init { + title = "Monthly Request Limit Reached" + isModal = true + defaultCloseOperation = DISPOSE_ON_CLOSE + contentPane = buildContent() + pack() + setLocationRelativeTo(null) + minimumSize = Dimension(420, 300) + } + + private fun buildContent(): JPanel { + val root = JPanel(BorderLayout(0, 0)) + root.border = EmptyBorder(24, 28, 20, 28) + root.background = UIManager.getColor("Panel.background") + + // Icon + title row + val titleRow = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)) + titleRow.isOpaque = false + val iconLabel = JLabel("⚠") + iconLabel.font = iconLabel.font.deriveFont(22f) + iconLabel.foreground = Color(230, 130, 0) + val titleLabel = JLabel("Request Limit Reached") + titleLabel.font = titleLabel.font.deriveFont(Font.BOLD, 15f) + titleRow.add(iconLabel) + titleRow.add(titleLabel) + + // Body text + val sub = SubscriptionManager.getInstance() + val used = sub.status.requestsUsed + val limit = sub.status.requestsLimit + val resetAt = formatResetDate(sub.status.resetAt) + + val bodyText = JTextArea( + "You've used $used/$limit AI requests this month.\n\n" + + "Your usage resets on $resetAt.\n\n" + + "Upgrade to Ultimate for unlimited requests across all modules." + ) + bodyText.isEditable = false + bodyText.isOpaque = false + bodyText.font = UIManager.getFont("Label.font") + bodyText.lineWrap = true + bodyText.wrapStyleWord = true + bodyText.border = EmptyBorder(12, 0, 16, 0) + + // Button row + val btnRow = JPanel(FlowLayout(FlowLayout.RIGHT, 8, 0)) + btnRow.isOpaque = false + + val refreshBtn = JButton("Refresh Plan") + refreshBtn.font = refreshBtn.font.deriveFont(11f) + refreshBtn.addActionListener { + val token = SessionManager.getInstance().token ?: return@addActionListener + refreshBtn.isEnabled = false + refreshBtn.text = "Refreshing..." + Thread { + SubscriptionManager.getInstance().refreshStatus(token) + SwingUtilities.invokeLater { + dispose() + } + }.start() + } + + val upgradeBtn = JButton("Upgrade to Ultimate — \$9/month") + upgradeBtn.font = upgradeBtn.font.deriveFont(Font.BOLD, 11f) + upgradeBtn.foreground = Color.WHITE + upgradeBtn.background = Color(80, 140, 255) + upgradeBtn.isBorderPainted = false + upgradeBtn.isOpaque = true + upgradeBtn.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + upgradeBtn.addActionListener { openCheckout() } + + btnRow.add(refreshBtn) + btnRow.add(upgradeBtn) + + root.add(titleRow, BorderLayout.NORTH) + root.add(bodyText, BorderLayout.CENTER) + root.add(btnRow, BorderLayout.SOUTH) + + return root + } + + private fun openCheckout() { + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().browse(URI("https://www.springforge.dev/pricing")) + } + } + + private fun formatResetDate(raw: String): String { + if (raw.isBlank()) return "the 1st of next month" + return try { + val instant = Instant.parse(raw) + val formatter = DateTimeFormatter + .ofPattern("MMMM d, yyyy", Locale.ENGLISH) + .withZone(ZoneId.systemDefault()) + formatter.format(instant) + } catch (e: Exception) { + raw + } + } + + companion object { + fun show(project: Project) { + SwingUtilities.invokeLater { + RequestLimitDialog(project).isVisible = true + } + } + } +} diff --git a/src/main/java/org/springforge/subscription/ui/SubscriptionPanel.kt b/src/main/java/org/springforge/subscription/ui/SubscriptionPanel.kt new file mode 100644 index 0000000..a72f96f --- /dev/null +++ b/src/main/java/org/springforge/subscription/ui/SubscriptionPanel.kt @@ -0,0 +1,271 @@ +package org.springforge.subscription.ui + +import com.intellij.ui.JBColor +import org.springforge.auth.SessionManager +import org.springforge.subscription.SubscriptionManager +import org.springforge.subscription.SubscriptionTier +import java.awt.* +import java.net.URI +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.swing.* +import javax.swing.border.EmptyBorder +import javax.swing.border.LineBorder + +/** + * Embedded subscription/plan panel shown as the "Plan" tab in the tool window. + * Refreshes itself automatically via SubscriptionManager.onStatusChanged. + */ +class SubscriptionPanel : JPanel(BorderLayout()) { + + init { + isOpaque = false + rebuild() + // Chain onto any existing callback (tool window header may already be registered) + val existing = SubscriptionManager.getInstance().onStatusChanged + SubscriptionManager.getInstance().onStatusChanged = { + existing?.invoke() + SwingUtilities.invokeLater { rebuild() } + } + } + + private fun rebuild() { + removeAll() + add(buildContent(), BorderLayout.CENTER) + revalidate() + repaint() + } + + private fun buildContent(): JPanel { + val root = JPanel(BorderLayout(0, 0)) + root.isOpaque = false + root.border = EmptyBorder(20, 20, 20, 20) + + // ── Title row ────────────────────────────────────────────── + val titleLabel = JLabel("Subscription & Pricing") + titleLabel.font = titleLabel.font.deriveFont(Font.BOLD, 15f) + titleLabel.border = EmptyBorder(0, 0, 16, 0) + root.add(titleLabel, BorderLayout.NORTH) + + // ── Plan cards ───────────────────────────────────────────── + val cardsPanel = JPanel(GridLayout(1, 2, 16, 0)) + cardsPanel.isOpaque = false + + val currentTier = SubscriptionManager.getInstance().status.tier + cardsPanel.add(buildCommunityCard(currentTier)) + cardsPanel.add(buildUltimateCard(currentTier)) + root.add(cardsPanel, BorderLayout.CENTER) + + // ── Footer note ──────────────────────────────────────────── + val footer = JLabel("Payments and billing are managed securely at springforge.dev") + footer.font = footer.font.deriveFont(10f) + footer.foreground = JBColor.GRAY + footer.horizontalAlignment = SwingConstants.CENTER + footer.border = EmptyBorder(14, 0, 0, 0) + root.add(footer, BorderLayout.SOUTH) + + return root + } + + private fun buildCommunityCard(current: SubscriptionTier): JPanel { + val isActive = current == SubscriptionTier.COMMUNITY + val card = JPanel(BorderLayout()) + card.background = UIManager.getColor("Panel.background") + card.border = if (isActive) LineBorder(Color(80, 140, 255), 2, true) + else LineBorder(JBColor.border(), 1, true) + + val inner = JPanel() + inner.layout = BoxLayout(inner, BoxLayout.Y_AXIS) + inner.isOpaque = false + inner.border = EmptyBorder(16, 16, 16, 16) + + val name = JLabel("Community") + name.font = name.font.deriveFont(Font.BOLD, 14f) + name.alignmentX = Component.LEFT_ALIGNMENT + + val price = JLabel("Free") + price.font = price.font.deriveFont(Font.BOLD, 24f) + price.alignmentX = Component.LEFT_ALIGNMENT + + val sep = JSeparator() + sep.alignmentX = Component.LEFT_ALIGNMENT + sep.maximumSize = Dimension(Int.MAX_VALUE, 1) + + inner.add(name) + inner.add(Box.createVerticalStrut(4)) + inner.add(price) + inner.add(Box.createVerticalStrut(12)) + inner.add(sep) + inner.add(Box.createVerticalStrut(12)) + + listOf( + "✓ All 4 modules — full access", + "✓ CI/CD generation", + "✓ Runtime analysis", + "✓ Code quality analysis", + "✓ Code generation", + "⚠ 5 AI requests/month" + ).forEach { inner.add(featureRow(it)) } + + inner.add(Box.createVerticalStrut(12)) + + if (isActive) { + val sub = SubscriptionManager.getInstance().status + val usageBar = JLabel("${sub.requestsUsed} / ${sub.requestsLimit} requests used this month") + usageBar.font = usageBar.font.deriveFont(10f) + usageBar.foreground = if (sub.requestsUsed >= sub.requestsLimit) Color(200, 60, 60) else JBColor.GRAY + usageBar.alignmentX = Component.LEFT_ALIGNMENT + inner.add(usageBar) + + inner.add(Box.createVerticalStrut(4)) + val badge = JLabel("✦ Current Plan") + badge.font = badge.font.deriveFont(Font.BOLD, 10f) + badge.foreground = Color(80, 140, 255) + badge.alignmentX = Component.LEFT_ALIGNMENT + inner.add(badge) + } + + card.add(inner, BorderLayout.CENTER) + return card + } + + private fun buildUltimateCard(current: SubscriptionTier): JPanel { + val isActive = current == SubscriptionTier.ULTIMATE + val card = JPanel(BorderLayout()) + card.background = UIManager.getColor("Panel.background") + card.border = if (isActive) LineBorder(Color(80, 200, 120), 2, true) + else LineBorder(JBColor.border(), 1, true) + + val inner = JPanel() + inner.layout = BoxLayout(inner, BoxLayout.Y_AXIS) + inner.isOpaque = false + inner.border = EmptyBorder(16, 16, 16, 16) + + val name = JLabel("Ultimate") + name.font = name.font.deriveFont(Font.BOLD, 14f) + name.alignmentX = Component.LEFT_ALIGNMENT + + val priceRow = JPanel(FlowLayout(FlowLayout.LEFT, 4, 0)) + priceRow.isOpaque = false + priceRow.alignmentX = Component.LEFT_ALIGNMENT + val price = JLabel("\$9") + price.font = price.font.deriveFont(Font.BOLD, 24f) + val per = JLabel("/month") + per.font = per.font.deriveFont(12f) + per.foreground = JBColor.GRAY + priceRow.add(price) + priceRow.add(per) + + val sep = JSeparator() + sep.alignmentX = Component.LEFT_ALIGNMENT + sep.maximumSize = Dimension(Int.MAX_VALUE, 1) + + inner.add(name) + inner.add(Box.createVerticalStrut(4)) + inner.add(priceRow) + inner.add(Box.createVerticalStrut(12)) + inner.add(sep) + inner.add(Box.createVerticalStrut(12)) + + listOf( + "✓ All 4 modules — full access", + "✓ CI/CD generation", + "✓ Runtime analysis", + "✓ Code quality analysis", + "✓ Code generation", + "✓ Unlimited AI requests" + ).forEach { inner.add(featureRow(it)) } + + inner.add(Box.createVerticalStrut(12)) + + if (isActive) { + val sub = SubscriptionManager.getInstance().status + + if (sub.expiresAt.isNotBlank()) { + // Pending cancellation + val cancelNote = JLabel("Cancels on ${formatDate(sub.expiresAt)}") + cancelNote.font = cancelNote.font.deriveFont(Font.BOLD, 11f) + cancelNote.foreground = Color(200, 150, 0) + cancelNote.alignmentX = Component.LEFT_ALIGNMENT + inner.add(cancelNote) + } else { + // Active — show Cancel Plan + val cancelBtn = JButton("Cancel Plan") + cancelBtn.font = cancelBtn.font.deriveFont(11f) + cancelBtn.foreground = Color(200, 60, 60) + cancelBtn.isOpaque = false + cancelBtn.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + cancelBtn.alignmentX = Component.LEFT_ALIGNMENT + cancelBtn.maximumSize = Dimension(Int.MAX_VALUE, 30) + cancelBtn.toolTipText = "Manage or cancel your subscription at springforge.dev" + cancelBtn.addActionListener { openUrl("https://www.springforge.dev/pricing") } + inner.add(cancelBtn) + } + + inner.add(Box.createVerticalStrut(6)) + + val refreshBtn = JButton("Refresh Plan") + refreshBtn.font = refreshBtn.font.deriveFont(11f) + refreshBtn.alignmentX = Component.LEFT_ALIGNMENT + refreshBtn.maximumSize = Dimension(Int.MAX_VALUE, 30) + refreshBtn.toolTipText = "Re-check your subscription status from the server" + refreshBtn.addActionListener { + val token = SessionManager.getInstance().token ?: return@addActionListener + refreshBtn.isEnabled = false + refreshBtn.text = "Checking..." + Thread { + SubscriptionManager.getInstance().refreshStatus(token) + // rebuild() is triggered automatically via onStatusChanged + }.start() + } + inner.add(refreshBtn) + + inner.add(Box.createVerticalStrut(6)) + val badge = JLabel("✦ Current Plan") + badge.font = badge.font.deriveFont(Font.BOLD, 10f) + badge.foreground = Color(80, 200, 120) + badge.alignmentX = Component.LEFT_ALIGNMENT + inner.add(badge) + + } else { + val upgradeBtn = JButton("Upgrade to Ultimate") + upgradeBtn.font = upgradeBtn.font.deriveFont(Font.BOLD, 12f) + upgradeBtn.foreground = Color.WHITE + upgradeBtn.background = Color(80, 140, 255) + upgradeBtn.isBorderPainted = false + upgradeBtn.isOpaque = true + upgradeBtn.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + upgradeBtn.alignmentX = Component.LEFT_ALIGNMENT + upgradeBtn.maximumSize = Dimension(Int.MAX_VALUE, 34) + upgradeBtn.addActionListener { openUrl("https://www.springforge.dev/pricing") } + inner.add(upgradeBtn) + } + + card.add(inner, BorderLayout.CENTER) + return card + } + + private fun featureRow(text: String): JLabel { + val lbl = JLabel(text) + lbl.font = lbl.font.deriveFont(11f) + lbl.alignmentX = Component.LEFT_ALIGNMENT + lbl.border = EmptyBorder(2, 0, 2, 0) + if (text.startsWith("⚠")) lbl.foreground = Color(180, 120, 0) + return lbl + } + + private fun formatDate(raw: String): String { + return try { + val instant = Instant.parse(raw) + DateTimeFormatter.ofPattern("M/d/yyyy", Locale.ENGLISH) + .withZone(ZoneId.systemDefault()) + .format(instant) + } catch (e: Exception) { raw } + } + + private fun openUrl(url: String) { + if (Desktop.isDesktopSupported()) Desktop.getDesktop().browse(URI(url)) + } +} diff --git a/src/main/java/org/springforge/toolwindow/SpringForgeToolWindowPanel.kt b/src/main/java/org/springforge/toolwindow/SpringForgeToolWindowPanel.kt index ba64ec0..74175b1 100644 --- a/src/main/java/org/springforge/toolwindow/SpringForgeToolWindowPanel.kt +++ b/src/main/java/org/springforge/toolwindow/SpringForgeToolWindowPanel.kt @@ -10,7 +10,12 @@ import org.springforge.toolwindow.panels.CodeGenerationPanel import org.springforge.toolwindow.panels.QualityAssurancePanel import org.springforge.toolwindow.panels.RuntimeAnalysisPanel import org.springforge.feedback.ui.FeedbackDialog +import org.springforge.subscription.SubscriptionManager +import org.springforge.subscription.SubscriptionTier +import org.springforge.subscription.ui.SubscriptionPanel import java.awt.BorderLayout +import java.awt.Color +import java.awt.Cursor import java.awt.Dimension import java.awt.Font import javax.swing.BorderFactory @@ -28,12 +33,18 @@ class SpringForgeToolWindowPanel(private val project: Project) : JPanel() { private val tabbedPane = JBTabbedPane() + // Subscription header components — updated live via SubscriptionManager callback + private var usageLabel: JLabel? = null + private var tierBadge: JLabel? = null + private var upgradeBtn: JButton? = null + // Individual module panels private val codeGenPanel = CodeGenerationPanel(project) private val cicdPanel = CICDPanel(project) private val qualityPanel = QualityAssurancePanel(project) private val runtimePanel = RuntimeAnalysisPanel(project) private val auditPanel = AuditDashboardPanel(project) + private val planPanel = SubscriptionPanel() init { layout = BorderLayout() @@ -43,6 +54,9 @@ class SpringForgeToolWindowPanel(private val project: Project) : JPanel() { private fun setupUI() { // Add header add(createHeader(), BorderLayout.NORTH) + // Refresh header live when subscription status changes + SubscriptionManager.getInstance().onStatusChanged = ::refreshSubscriptionHeader + refreshSubscriptionHeader() // Add tabbed pane with all modules tabbedPane.apply { @@ -51,6 +65,7 @@ class SpringForgeToolWindowPanel(private val project: Project) : JPanel() { addTab("Quality", qualityPanel) addTab("Runtime", runtimePanel) addTab("Audit", auditPanel) + addTab("Plan", planPanel) } add(tabbedPane, BorderLayout.CENTER) @@ -74,10 +89,33 @@ class SpringForgeToolWindowPanel(private val project: Project) : JPanel() { val session = SessionManager.getInstance() if (session.isLoggedIn) { - val userLabel = JLabel(session.currentUser?.fullName ?: session.currentUser?.email ?: "") - userLabel.foreground = JBColor.GRAY - userLabel.font = userLabel.font.deriveFont(11f) - rightPanel.add(userLabel) + // Usage label — shown for Community, hidden for Ultimate + usageLabel = JLabel() + usageLabel!!.font = usageLabel!!.font.deriveFont(10f) + rightPanel.add(usageLabel!!) + + // Tier badge — shown for Ultimate, hidden for Community (clickable → Plan tab) + tierBadge = JLabel("✦ Ultimate") + tierBadge!!.font = tierBadge!!.font.deriveFont(Font.BOLD, 10f) + tierBadge!!.foreground = Color(80, 200, 120) + tierBadge!!.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + tierBadge!!.toolTipText = "Manage your plan" + tierBadge!!.addMouseListener(object : java.awt.event.MouseAdapter() { + override fun mouseClicked(e: java.awt.event.MouseEvent) = switchToTab("plan") + }) + rightPanel.add(tierBadge!!) + + // Upgrade button — shown for Community only + upgradeBtn = JButton("✦ Upgrade") + upgradeBtn!!.preferredSize = Dimension(85, 24) + upgradeBtn!!.font = upgradeBtn!!.font.deriveFont(Font.BOLD, 10f) + upgradeBtn!!.foreground = Color.WHITE + upgradeBtn!!.background = Color(80, 140, 255) + upgradeBtn!!.isBorderPainted = false + upgradeBtn!!.isOpaque = true + upgradeBtn!!.toolTipText = "Upgrade to Ultimate for unlimited requests" + upgradeBtn!!.addActionListener { switchToTab("plan") } + rightPanel.add(upgradeBtn!!) val feedbackButton = JButton("\u2606 Feedback") feedbackButton.preferredSize = Dimension(90, 24) @@ -120,6 +158,20 @@ class SpringForgeToolWindowPanel(private val project: Project) : JPanel() { return headerPanel } + /** Updates the subscription UI in the header without rebuilding the whole panel. */ + private fun refreshSubscriptionHeader() { + val sub = SubscriptionManager.getInstance().status + val isUltimate = sub.tier == SubscriptionTier.ULTIMATE + + usageLabel?.apply { + text = sub.usageDisplay() + foreground = if (sub.requestsUsed >= sub.requestsLimit) Color(200, 60, 60) else JBColor.GRAY + isVisible = !isUltimate + } + tierBadge?.isVisible = isUltimate + upgradeBtn?.isVisible = !isUltimate + } + /** * Switch to a specific tab by name */ @@ -130,6 +182,7 @@ class SpringForgeToolWindowPanel(private val project: Project) : JPanel() { "quality", "qa" -> tabbedPane.selectedIndex = 2 "runtime", "debugger" -> tabbedPane.selectedIndex = 3 "audit" -> tabbedPane.selectedIndex = 4 + "plan", "pricing" -> tabbedPane.selectedIndex = 5 } } diff --git a/src/main/java/org/springforge/toolwindow/panels/CICDPanel.kt b/src/main/java/org/springforge/toolwindow/panels/CICDPanel.kt index e6a5260..55ce755 100644 --- a/src/main/java/org/springforge/toolwindow/panels/CICDPanel.kt +++ b/src/main/java/org/springforge/toolwindow/panels/CICDPanel.kt @@ -24,6 +24,9 @@ import org.springforge.cicdassistant.explainability.ui.ExplainabilityPanel import org.springforge.cicdassistant.validation.ValidationResult import org.springforge.cicdassistant.validation.ValidationService import org.springforge.cicdassistant.validation.ui.ValidationResultsPanel +import org.springforge.auth.SessionManager +import org.springforge.subscription.SubscriptionManager +import org.springforge.subscription.ui.RequestLimitDialog import java.awt.* import java.io.File import javax.swing.* @@ -537,6 +540,13 @@ class CICDPanel(private val project: Project) : JPanel() { return } + // Subscription gate — check request limit before making AI call + val subManager = SubscriptionManager.getInstance() + if (!subManager.canMakeRequest()) { + RequestLimitDialog.show(project) + return + } + val isLocal = isLocalSelected val githubUrl = githubUrlField.text.trim() val branch = githubBranchComboBox.selectedItem as? String ?: "main" @@ -623,6 +633,11 @@ class CICDPanel(private val project: Project) : JPanel() { success = true ) + // Increment subscription usage after successful generation + SessionManager.getInstance().token?.let { tok -> + SubscriptionManager.getInstance().incrementUsage(tok) + } + ApplicationManager.getApplication().invokeLater { validateButton.isEnabled = true explainButton.isEnabled = true diff --git a/src/main/java/org/springforge/toolwindow/panels/CodeGenerationPanel.kt b/src/main/java/org/springforge/toolwindow/panels/CodeGenerationPanel.kt index 30f4d1a..2475874 100644 --- a/src/main/java/org/springforge/toolwindow/panels/CodeGenerationPanel.kt +++ b/src/main/java/org/springforge/toolwindow/panels/CodeGenerationPanel.kt @@ -17,6 +17,9 @@ import org.springforge.codegeneration.parser.YamlWriter import org.springforge.codegeneration.service.GenerationResult import org.springforge.codegeneration.service.GenerationResultService import org.springforge.codegeneration.ui.EntityDesignerDialog +import org.springforge.auth.SessionManager +import org.springforge.subscription.SubscriptionManager +import org.springforge.subscription.ui.RequestLimitDialog import java.awt.* import java.io.File import javax.swing.* @@ -212,6 +215,12 @@ class CodeGenerationPanel(private val project: Project) : JPanel() { // ─── Entity Designer → save YAML → trigger code generation ───── private fun openEntityDesignerAndGenerate() { + // Subscription gate + if (!SubscriptionManager.getInstance().canMakeRequest()) { + RequestLimitDialog.show(project) + return + } + val baseDir = project.basePath ?: return // Try to load existing input.yml so the dialog pre-populates @@ -262,6 +271,11 @@ class CodeGenerationPanel(private val project: Project) : JPanel() { val action = GenerateCodeAction() val event = createActionEvent() action.actionPerformed(event) + + // Increment subscription usage after generation is triggered + SessionManager.getInstance().token?.let { tok -> + Thread { SubscriptionManager.getInstance().incrementUsage(tok) }.start() + } } // ─── Generation Results Display ─────────────────────────────── diff --git a/src/main/java/org/springforge/toolwindow/panels/QualityAssurancePanel.kt b/src/main/java/org/springforge/toolwindow/panels/QualityAssurancePanel.kt index 77d7809..be933b8 100644 --- a/src/main/java/org/springforge/toolwindow/panels/QualityAssurancePanel.kt +++ b/src/main/java/org/springforge/toolwindow/panels/QualityAssurancePanel.kt @@ -19,6 +19,9 @@ import org.springforge.qualityassurance.model.FileFeatureModel import org.springforge.qualityassurance.model.FixSuggestion import org.springforge.qualityassurance.model.ProjectFixResult import org.springforge.qualityassurance.network.MLServiceClient +import org.springforge.auth.SessionManager +import org.springforge.subscription.SubscriptionManager +import org.springforge.subscription.ui.RequestLimitDialog import java.awt.* import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent @@ -491,6 +494,12 @@ class QualityAssurancePanel(private val project: Project) : JPanel() { // ── Analysis workflow (unchanged logic) ─────────────────────────────────── private fun runAnalysis() { + // Subscription gate + if (!SubscriptionManager.getInstance().canMakeRequest()) { + RequestLimitDialog.show(project) + return + } + analyzeButton.isEnabled = false; viewReportButton.isEnabled = false progressBar.isVisible = true; setStatus("Scanning project files…", SF.textSecondary) val archKey = selectedArchitecture @@ -530,6 +539,10 @@ class QualityAssurancePanel(private val project: Project) : JPanel() { setStatus("$emoji Analysis complete — ${result.overall_display}", SF.green) resetButtons(); viewReportButton.isEnabled = true showReportDialog(result, fixes) + // Increment subscription usage after successful analysis + SessionManager.getInstance().token?.let { tok -> + Thread { SubscriptionManager.getInstance().incrementUsage(tok) }.start() + } } } catch (ex: Exception) { setStatus("❌ ${ex.message}", SF.red); resetButtons() diff --git a/src/main/java/org/springforge/toolwindow/panels/RuntimeAnalysisPanel.kt b/src/main/java/org/springforge/toolwindow/panels/RuntimeAnalysisPanel.kt index ae7a288..992d2c0 100644 --- a/src/main/java/org/springforge/toolwindow/panels/RuntimeAnalysisPanel.kt +++ b/src/main/java/org/springforge/toolwindow/panels/RuntimeAnalysisPanel.kt @@ -9,6 +9,9 @@ import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane import org.springforge.cicdassistant.audit.AuditService import org.springforge.runtimeanalysis.service.RuntimeAnalysisService +import org.springforge.auth.SessionManager +import org.springforge.subscription.SubscriptionManager +import org.springforge.subscription.ui.RequestLimitDialog import java.awt.* import java.awt.datatransfer.StringSelection import java.awt.event.MouseAdapter @@ -213,6 +216,12 @@ class RuntimeAnalysisPanel(private val project: Project) : JPanel() { // ───────────────────────────────────────────────────────────── private fun analyzeError(errorText: String) { + // Subscription gate + if (!SubscriptionManager.getInstance().canMakeRequest()) { + RequestLimitDialog.show(project) + return + } + showStatus("⏳ Analyzing…") val startMs = System.currentTimeMillis() @@ -229,6 +238,10 @@ class RuntimeAnalysisPanel(private val project: Project) : JPanel() { durationMs = System.currentTimeMillis() - startMs, success = true ) + // Increment subscription usage after successful analysis + SessionManager.getInstance().token?.let { tok -> + Thread { SubscriptionManager.getInstance().incrementUsage(tok) }.start() + } } }, onError = { err ->