From 6429ec5f6e4c85d455a1484d92f2a5e96c6fe303 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 13:55:24 +0600 Subject: [PATCH 01/68] up --- prisma/schema.prisma | 288 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 280 insertions(+), 8 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eb3cd359..f126fac3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -159,13 +159,16 @@ model Store { timezone String @default("Asia/Dhaka") locale String @default("bn") - // Subscription - subscriptionPlan SubscriptionPlan @default(FREE) - subscriptionStatus SubscriptionStatus @default(TRIAL) + // Subscription (legacy fields kept for backward compat) + subscriptionPlan SubscriptionPlanTier @default(FREE) + subscriptionStatus SubscriptionStatus @default(TRIAL) trialEndsAt DateTime? subscriptionEndsAt DateTime? - productLimit Int @default(10) - orderLimit Int @default(100) + productLimit Int @default(10) + orderLimit Int @default(100) + + // New subscription system relation + subscription Subscription? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? @@ -975,19 +978,288 @@ enum DiscountType { FREE_SHIPPING } -enum SubscriptionPlan { +enum SubscriptionPlanTier { FREE BASIC PRO ENTERPRISE + CUSTOM } enum SubscriptionStatus { TRIAL ACTIVE + GRACE_PERIOD PAST_DUE - CANCELED - PAUSED + EXPIRED + SUSPENDED + CANCELLED +} + +enum SubPaymentStatus { + PENDING + SUCCESS + FAILED + REFUNDED +} + +enum BillingCycle { + MONTHLY + YEARLY +} + +enum SubscriptionChangeType { + CREATED + UPGRADED + DOWNGRADED + RENEWED + CANCELLED + SUSPENDED + REACTIVATED + EXTENDED + GRACE_ENTERED + EXPIRED + TRIAL_STARTED + TRIAL_CONVERTED + PAYMENT_FAILED + PAYMENT_SUCCESS + FEATURE_OVERRIDE + PLAN_ASSIGNED +} + +// ============================================================================ +// SUBSCRIPTION MANAGEMENT SYSTEM +// ============================================================================ + +model SubscriptionPlanModel { + id String @id @default(cuid()) + name String @unique + slug String @unique + description String? + tier SubscriptionPlanTier @default(BASIC) + monthlyPrice Float @default(0) + yearlyPrice Float @default(0) + trialDays Int @default(7) + gracePeriodDays Int @default(3) + + // Feature limits + maxProducts Int @default(10) + maxStaff Int @default(2) + storageLimitMb Int @default(500) + maxOrders Int @default(100) + posEnabled Boolean @default(false) + accountingEnabled Boolean @default(false) + customDomainEnabled Boolean @default(false) + apiAccessEnabled Boolean @default(false) + + // Plan visibility + isPublic Boolean @default(true) + isActive Boolean @default(true) + sortOrder Int @default(0) + + // Metadata + badge String? // "Popular", "Best Value", etc. + features String? // JSON array of feature descriptions for display + + subscriptions Subscription[] + scheduledDowngrades Subscription[] @relation("DowngradePlan") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([isActive, isPublic, sortOrder]) + @@index([tier]) + @@index([slug]) + @@map("subscription_plans") +} + +model Subscription { + id String @id @default(cuid()) + storeId String @unique + store Store @relation(fields: [storeId], references: [id], onDelete: Cascade) + + planId String + plan SubscriptionPlanModel @relation(fields: [planId], references: [id]) + + status SubscriptionStatus @default(TRIAL) + billingCycle BillingCycle @default(MONTHLY) + + // Pricing (can be overridden per subscription) + priceOverride Float? + currentPrice Float @default(0) + + // Lifecycle dates + trialStartedAt DateTime? + trialEndsAt DateTime? + currentPeriodStart DateTime? + currentPeriodEnd DateTime? + graceEndsAt DateTime? + cancelledAt DateTime? + suspendedAt DateTime? + + // Auto-renewal + autoRenew Boolean @default(true) + cancelAtPeriodEnd Boolean @default(false) + + // Scheduled downgrade + scheduledDowngradePlanId String? + scheduledDowngradePlan SubscriptionPlanModel? @relation("DowngradePlan", fields: [scheduledDowngradePlanId], references: [id]) + scheduledDowngradeAt DateTime? + + // Feature overrides (JSON - allows superadmin to override) + featureOverrides String? + + // Payment tracking + lastPaymentAt DateTime? + nextPaymentAt DateTime? + failedPaymentCount Int @default(0) + maxPaymentRetries Int @default(3) + + // Terms + termsAcceptedAt DateTime? + termsVersion String? + + // Relations + payments SubPayment[] + invoices Invoice[] + logs SubscriptionLog[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([planId]) + @@index([currentPeriodEnd]) + @@index([trialEndsAt]) + @@index([status, currentPeriodEnd]) + @@map("subscriptions") +} + +model SubscriptionLog { + id String @id @default(cuid()) + subscriptionId String + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + + changeType SubscriptionChangeType + fromStatus SubscriptionStatus? + toStatus SubscriptionStatus? + fromPlanId String? + toPlanId String? + + // Change details + reason String? + metadata String? // JSON - additional context + performedBy String? // User ID + performedByRole String? // superadmin, store_owner, system + ipAddress String? + + createdAt DateTime @default(now()) + + @@index([subscriptionId, createdAt]) + @@index([changeType, createdAt]) + @@index([performedBy]) + @@map("subscription_logs") +} + +model SubPayment { + id String @id @default(cuid()) + subscriptionId String + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + invoiceId String? + invoice Invoice? @relation(fields: [invoiceId], references: [id]) + + amount Float + currency String @default("BDT") + status SubPaymentStatus @default(PENDING) + + // Gateway info + gateway String @default("manual") // stripe, bkash, nagad, sslcommerz, manual + gatewayTransactionId String? @unique + gatewayResponse String? // JSON - full gateway response + + // Idempotency + idempotencyKey String? @unique + + // Retry tracking + retryCount Int @default(0) + maxRetries Int @default(3) + nextRetryAt DateTime? + lastError String? + + // Metadata + metadata String? // JSON + refundedAmount Float? + refundReason String? + refundedAt DateTime? + + paidAt DateTime? + failedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([subscriptionId, status]) + @@index([subscriptionId, createdAt]) + @@index([status, nextRetryAt]) + @@index([gateway, status]) + @@index([gatewayTransactionId]) + @@map("sub_payments") +} + +model Invoice { + id String @id @default(cuid()) + subscriptionId String + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) + + invoiceNumber String @unique + status String @default("draft") // draft, issued, paid, void, refunded + + subtotal Float @default(0) + taxAmount Float @default(0) + discountAmount Float @default(0) + totalAmount Float @default(0) + + currency String @default("BDT") + + // Period + periodStart DateTime + periodEnd DateTime + + // Dates + issuedAt DateTime? + dueAt DateTime? + paidAt DateTime? + + // Notes + notes String? + + items InvoiceItem[] + payments SubPayment[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([subscriptionId, createdAt]) + @@index([status]) + @@index([invoiceNumber]) + @@map("invoices") +} + +model InvoiceItem { + id String @id @default(cuid()) + invoiceId String + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + description String + quantity Int @default(1) + unitPrice Float + totalPrice Float + + createdAt DateTime @default(now()) + + @@index([invoiceId]) + @@map("invoice_items") } enum RequestStatus { From a6d914a7a5abd38a10182e6447674c3fa9132f93 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 13:55:32 +0600 Subject: [PATCH 02/68] up --- src/lib/subscription/types.ts | 137 ++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/lib/subscription/types.ts diff --git a/src/lib/subscription/types.ts b/src/lib/subscription/types.ts new file mode 100644 index 00000000..4eef9a34 --- /dev/null +++ b/src/lib/subscription/types.ts @@ -0,0 +1,137 @@ +/** + * Subscription System Type Definitions + * + * Centralized types for the subscription management system. + */ + +import type { + SubscriptionStatus, + SubscriptionPlanTier, + BillingCycle, + SubscriptionChangeType, + SubPaymentStatus, +} from '@prisma/client'; + +export type { + SubscriptionStatus, + SubscriptionPlanTier, + BillingCycle, + SubscriptionChangeType, + SubPaymentStatus, +}; + +export interface FeatureLimits { + maxProducts: number; + maxStaff: number; + storageLimitMb: number; + maxOrders: number; + posEnabled: boolean; + accountingEnabled: boolean; + customDomainEnabled: boolean; + apiAccessEnabled: boolean; +} + +export interface SubscriptionWithPlan { + id: string; + storeId: string; + planId: string; + status: SubscriptionStatus; + billingCycle: BillingCycle; + currentPrice: number; + priceOverride: number | null; + trialStartedAt: Date | null; + trialEndsAt: Date | null; + currentPeriodStart: Date | null; + currentPeriodEnd: Date | null; + graceEndsAt: Date | null; + cancelledAt: Date | null; + suspendedAt: Date | null; + autoRenew: boolean; + cancelAtPeriodEnd: boolean; + featureOverrides: string | null; + failedPaymentCount: number; + plan: { + id: string; + name: string; + slug: string; + tier: SubscriptionPlanTier; + monthlyPrice: number; + yearlyPrice: number; + maxProducts: number; + maxStaff: number; + storageLimitMb: number; + maxOrders: number; + posEnabled: boolean; + accountingEnabled: boolean; + customDomainEnabled: boolean; + apiAccessEnabled: boolean; + features: string | null; + badge: string | null; + }; +} + +export interface SubscriptionDashboardData { + subscription: SubscriptionWithPlan | null; + currentPlan: string; + status: SubscriptionStatus; + expiryDate: Date | null; + remainingDays: number; + isExpiringSoon: boolean; + featureLimits: FeatureLimits; + usage: { + productsUsed: number; + staffUsed: number; + ordersUsed: number; + }; +} + +export interface PaymentCheckoutRequest { + planId: string; + billingCycle: BillingCycle; + gateway: string; + returnUrl?: string; +} + +export interface PaymentGatewayResult { + success: boolean; + transactionId?: string; + checkoutUrl?: string; + error?: string; + metadata?: Record; +} + +export interface PaymentWebhookPayload { + gateway: string; + event: string; + transactionId: string; + status: SubPaymentStatus; + amount: number; + currency: string; + metadata?: Record; + signature?: string; + rawBody?: string; +} + +export interface RevenueAnalytics { + mrr: number; + arr: number; + activeSubscriptions: number; + trialUsers: number; + expiredUsers: number; + suspendedUsers: number; + revenueByMonth: { month: string; revenue: number }[]; + paymentFailureRate: number; + upgradeCount: number; + downgradeCount: number; + churnRate: number; +} + +export interface SubscriptionReminderConfig { + daysBeforeExpiry: number[]; + graceWarningEnabled: boolean; +} + +export const DEFAULT_REMINDER_CONFIG: SubscriptionReminderConfig = { + daysBeforeExpiry: [5, 2, 0], + graceWarningEnabled: true, +}; From 3cc8c17b988d0b6c1a1bf8e6242df401b110e1a4 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 13:56:54 +0600 Subject: [PATCH 03/68] Add subscription feature enforcer & state machine Add subscription utilities: a feature-enforcer module that computes effective plan limits (including feature overrides), enforces product/staff/order limits, checks feature availability, and returns usage stats (uses prisma). Also add a strict subscription state-machine with defined valid transitions, helpers to validate transitions, list next states, determine required change types, access/read-only/blocked checks, and expiry/remaining-days helpers. --- src/lib/subscription/feature-enforcer.ts | 149 ++++++++++++++ src/lib/subscription/payment-gateway.ts | 241 +++++++++++++++++++++++ src/lib/subscription/state-machine.ts | 121 ++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 src/lib/subscription/feature-enforcer.ts create mode 100644 src/lib/subscription/payment-gateway.ts create mode 100644 src/lib/subscription/state-machine.ts diff --git a/src/lib/subscription/feature-enforcer.ts b/src/lib/subscription/feature-enforcer.ts new file mode 100644 index 00000000..d718bd5c --- /dev/null +++ b/src/lib/subscription/feature-enforcer.ts @@ -0,0 +1,149 @@ +/** + * Subscription Feature Enforcement + * + * Enforces feature limits based on subscription plan. + * Checks product counts, staff counts, and feature flags before allowing actions. + */ + +import { prisma } from '@/lib/prisma'; +import type { FeatureLimits, SubscriptionWithPlan } from './types'; + +export function getEffectiveFeatureLimits( + subscription: SubscriptionWithPlan | null +): FeatureLimits { + if (!subscription) { + return getFreeTierLimits(); + } + + const overrides = subscription.featureOverrides + ? (JSON.parse(subscription.featureOverrides) as Partial) + : {}; + + return { + maxProducts: overrides.maxProducts ?? subscription.plan.maxProducts, + maxStaff: overrides.maxStaff ?? subscription.plan.maxStaff, + storageLimitMb: overrides.storageLimitMb ?? subscription.plan.storageLimitMb, + maxOrders: overrides.maxOrders ?? subscription.plan.maxOrders, + posEnabled: overrides.posEnabled ?? subscription.plan.posEnabled, + accountingEnabled: overrides.accountingEnabled ?? subscription.plan.accountingEnabled, + customDomainEnabled: overrides.customDomainEnabled ?? subscription.plan.customDomainEnabled, + apiAccessEnabled: overrides.apiAccessEnabled ?? subscription.plan.apiAccessEnabled, + }; +} + +function getFreeTierLimits(): FeatureLimits { + return { + maxProducts: 10, + maxStaff: 1, + storageLimitMb: 100, + maxOrders: 50, + posEnabled: false, + accountingEnabled: false, + customDomainEnabled: false, + apiAccessEnabled: false, + }; +} + +export interface FeatureCheckResult { + allowed: boolean; + currentUsage: number; + limit: number; + message?: string; +} + +export async function canCreateProduct( + storeId: string, + limits: FeatureLimits +): Promise { + const count = await prisma.product.count({ + where: { storeId, deletedAt: null }, + }); + + return { + allowed: count < limits.maxProducts, + currentUsage: count, + limit: limits.maxProducts, + message: count >= limits.maxProducts + ? `Product limit reached (${count}/${limits.maxProducts}). Upgrade your plan to add more products.` + : undefined, + }; +} + +export async function canAddStaff( + storeId: string, + limits: FeatureLimits +): Promise { + const count = await prisma.storeStaff.count({ + where: { storeId, isActive: true }, + }); + + return { + allowed: count < limits.maxStaff, + currentUsage: count, + limit: limits.maxStaff, + message: count >= limits.maxStaff + ? `Staff limit reached (${count}/${limits.maxStaff}). Upgrade your plan to add more team members.` + : undefined, + }; +} + +export async function canCreateOrder( + storeId: string, + limits: FeatureLimits +): Promise { + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const count = await prisma.order.count({ + where: { + storeId, + createdAt: { gte: startOfMonth }, + deletedAt: null, + }, + }); + + return { + allowed: count < limits.maxOrders, + currentUsage: count, + limit: limits.maxOrders, + message: count >= limits.maxOrders + ? `Monthly order limit reached (${count}/${limits.maxOrders}). Upgrade your plan to process more orders.` + : undefined, + }; +} + +export function canUseFeature( + feature: keyof Pick, + limits: FeatureLimits +): { allowed: boolean; message?: string } { + const featureNames: Record = { + posEnabled: 'Point of Sale', + accountingEnabled: 'Accounting', + customDomainEnabled: 'Custom Domain', + apiAccessEnabled: 'API Access', + }; + + return { + allowed: limits[feature], + message: !limits[feature] + ? `${featureNames[feature]} is not available on your current plan. Upgrade to unlock this feature.` + : undefined, + }; +} + +export async function getUsageStats(storeId: string) { + const startOfMonth = new Date(); + startOfMonth.setDate(1); + startOfMonth.setHours(0, 0, 0, 0); + + const [productsUsed, staffUsed, ordersUsed] = await Promise.all([ + prisma.product.count({ where: { storeId, deletedAt: null } }), + prisma.storeStaff.count({ where: { storeId, isActive: true } }), + prisma.order.count({ + where: { storeId, createdAt: { gte: startOfMonth }, deletedAt: null }, + }), + ]); + + return { productsUsed, staffUsed, ordersUsed }; +} diff --git a/src/lib/subscription/payment-gateway.ts b/src/lib/subscription/payment-gateway.ts new file mode 100644 index 00000000..27196e80 --- /dev/null +++ b/src/lib/subscription/payment-gateway.ts @@ -0,0 +1,241 @@ +/** + * Payment Gateway Abstraction (Strategy Pattern) + * + * Provides a gateway-agnostic interface for payment processing. + * Each gateway implements the PaymentGateway interface. + * + * Architecture follows the Strategy pattern for easy provider swapping. + */ + +import crypto from 'crypto'; + +export interface CreatePaymentIntent { + amount: number; + currency: string; + description: string; + metadata: Record; + returnUrl?: string; + customerId?: string; +} + +export interface PaymentResult { + success: boolean; + transactionId: string; + checkoutUrl?: string; + status: 'pending' | 'success' | 'failed'; + rawResponse?: Record; + error?: string; +} + +export interface RefundRequest { + transactionId: string; + amount: number; + reason?: string; +} + +export interface WebhookVerification { + isValid: boolean; + event?: string; + data?: Record; +} + +export interface PaymentGateway { + name: string; + createPayment(intent: CreatePaymentIntent): Promise; + verifyWebhook(payload: string, signature: string): Promise; + refund(request: RefundRequest): Promise; + getPaymentStatus(transactionId: string): Promise; +} + +// Stripe-like gateway implementation +class StripeGateway implements PaymentGateway { + name = 'stripe'; + private secretKey: string; + private webhookSecret: string; + + constructor() { + this.secretKey = process.env.STRIPE_SECRET_KEY ?? ''; + this.webhookSecret = process.env.STRIPE_WEBHOOK_SECRET ?? ''; + } + + async createPayment(intent: CreatePaymentIntent): Promise { + // In production: call Stripe API + // stripe.paymentIntents.create({ amount, currency, metadata }) + const transactionId = `pi_${crypto.randomUUID().replace(/-/g, '')}`; + + // Simulated — replace with actual Stripe SDK call + return { + success: true, + transactionId, + checkoutUrl: `https://checkout.stripe.com/pay/${transactionId}`, + status: 'pending', + rawResponse: { intentId: transactionId, ...intent }, + }; + } + + async verifyWebhook(payload: string, signature: string): Promise { + if (!this.webhookSecret) { + return { isValid: false }; + } + + const expectedSig = crypto + .createHmac('sha256', this.webhookSecret) + .update(payload) + .digest('hex'); + + const isValid = crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSig) + ); + + if (!isValid) return { isValid: false }; + + const data = JSON.parse(payload) as Record; + return { isValid: true, event: data.type as string, data }; + } + + async refund(request: RefundRequest): Promise { + // In production: call Stripe refund API + return { + success: true, + transactionId: `re_${crypto.randomUUID().replace(/-/g, '')}`, + status: 'success', + rawResponse: { refundedAmount: request.amount }, + }; + } + + async getPaymentStatus(transactionId: string): Promise { + // In production: retrieve from Stripe API + return { + success: true, + transactionId, + status: 'success', + }; + } +} + +// SSLCommerz gateway for BD market +class SSLCommerzGateway implements PaymentGateway { + name = 'sslcommerz'; + + async createPayment(intent: CreatePaymentIntent): Promise { + const transactionId = `ssl_${Date.now()}_${crypto.randomUUID().slice(0, 8)}`; + + // In production: call SSLCommerz session API + return { + success: true, + transactionId, + checkoutUrl: `https://sandbox.sslcommerz.com/gwprocess/v4/gw.php?Q=pay&SESSIONKEY=${transactionId}`, + status: 'pending', + rawResponse: { sessionKey: transactionId, ...intent }, + }; + } + + async verifyWebhook(payload: string, _signature: string): Promise { + const data = JSON.parse(payload) as Record; + // SSLCommerz uses IPN validation via verify API + return { isValid: true, event: 'payment', data }; + } + + async refund(request: RefundRequest): Promise { + return { + success: true, + transactionId: `ssl_ref_${Date.now()}`, + status: 'success', + rawResponse: { refundedAmount: request.amount, originalTxn: request.transactionId }, + }; + } + + async getPaymentStatus(transactionId: string): Promise { + return { success: true, transactionId, status: 'success' }; + } +} + +// bKash mobile payment gateway +class BkashGateway implements PaymentGateway { + name = 'bkash'; + + async createPayment(intent: CreatePaymentIntent): Promise { + const transactionId = `bk_${Date.now()}_${crypto.randomUUID().slice(0, 8)}`; + return { + success: true, + transactionId, + checkoutUrl: `https://tokenized.sandbox.bka.sh/v1.2.0-beta/checkout/payment/create`, + status: 'pending', + rawResponse: { paymentID: transactionId, ...intent }, + }; + } + + async verifyWebhook(payload: string, _signature: string): Promise { + const data = JSON.parse(payload) as Record; + return { isValid: true, event: 'payment', data }; + } + + async refund(request: RefundRequest): Promise { + return { + success: true, + transactionId: `bk_ref_${Date.now()}`, + status: 'success', + rawResponse: { refundedAmount: request.amount }, + }; + } + + async getPaymentStatus(transactionId: string): Promise { + return { success: true, transactionId, status: 'success' }; + } +} + +// Manual / Cash payment (for testing or offline) +class ManualGateway implements PaymentGateway { + name = 'manual'; + + async createPayment(intent: CreatePaymentIntent): Promise { + return { + success: true, + transactionId: `manual_${Date.now()}`, + status: 'pending', + rawResponse: { ...intent, note: 'Pending manual confirmation' }, + }; + } + + async verifyWebhook(_payload: string, _signature: string): Promise { + return { isValid: true, event: 'manual_confirmation' }; + } + + async refund(request: RefundRequest): Promise { + return { + success: true, + transactionId: `manual_ref_${Date.now()}`, + status: 'success', + rawResponse: { refundedAmount: request.amount }, + }; + } + + async getPaymentStatus(transactionId: string): Promise { + return { success: true, transactionId, status: 'success' }; + } +} + +// Gateway registry for the strategy pattern +const gatewayRegistry: Record PaymentGateway> = { + stripe: () => new StripeGateway(), + sslcommerz: () => new SSLCommerzGateway(), + bkash: () => new BkashGateway(), + manual: () => new ManualGateway(), +}; + +export function getPaymentGateway(name: string): PaymentGateway { + const factory = gatewayRegistry[name]; + if (!factory) { + throw new Error(`Payment gateway "${name}" is not registered`); + } + return factory(); +} + +export function getAvailableGateways(): string[] { + return Object.keys(gatewayRegistry); +} + +export function registerGateway(name: string, factory: () => PaymentGateway): void { + gatewayRegistry[name] = factory; +} diff --git a/src/lib/subscription/state-machine.ts b/src/lib/subscription/state-machine.ts new file mode 100644 index 00000000..3f69ef30 --- /dev/null +++ b/src/lib/subscription/state-machine.ts @@ -0,0 +1,121 @@ +/** + * Subscription State Machine + * + * Manages subscription lifecycle state transitions with validation. + * Implements a strict finite state machine where each transition must + * be explicitly defined (no implicit jumps between states). + */ + +import type { SubscriptionStatus, SubscriptionChangeType } from '@prisma/client'; + +interface StateTransition { + from: SubscriptionStatus; + to: SubscriptionStatus; + changeType: SubscriptionChangeType; +} + +const VALID_TRANSITIONS: StateTransition[] = [ + // Trial transitions + { from: 'TRIAL', to: 'ACTIVE', changeType: 'TRIAL_CONVERTED' }, + { from: 'TRIAL', to: 'EXPIRED', changeType: 'EXPIRED' }, + { from: 'TRIAL', to: 'CANCELLED', changeType: 'CANCELLED' }, + + // Active transitions + { from: 'ACTIVE', to: 'ACTIVE', changeType: 'RENEWED' }, + { from: 'ACTIVE', to: 'ACTIVE', changeType: 'UPGRADED' }, + { from: 'ACTIVE', to: 'ACTIVE', changeType: 'DOWNGRADED' }, + { from: 'ACTIVE', to: 'GRACE_PERIOD', changeType: 'GRACE_ENTERED' }, + { from: 'ACTIVE', to: 'CANCELLED', changeType: 'CANCELLED' }, + { from: 'ACTIVE', to: 'SUSPENDED', changeType: 'SUSPENDED' }, + + // Grace period transitions + { from: 'GRACE_PERIOD', to: 'ACTIVE', changeType: 'PAYMENT_SUCCESS' }, + { from: 'GRACE_PERIOD', to: 'EXPIRED', changeType: 'EXPIRED' }, + { from: 'GRACE_PERIOD', to: 'SUSPENDED', changeType: 'SUSPENDED' }, + + // Past due transitions + { from: 'PAST_DUE', to: 'ACTIVE', changeType: 'PAYMENT_SUCCESS' }, + { from: 'PAST_DUE', to: 'GRACE_PERIOD', changeType: 'GRACE_ENTERED' }, + { from: 'PAST_DUE', to: 'EXPIRED', changeType: 'EXPIRED' }, + { from: 'PAST_DUE', to: 'SUSPENDED', changeType: 'SUSPENDED' }, + + // Expired transitions + { from: 'EXPIRED', to: 'ACTIVE', changeType: 'REACTIVATED' }, + { from: 'EXPIRED', to: 'SUSPENDED', changeType: 'SUSPENDED' }, + + // Suspended transitions + { from: 'SUSPENDED', to: 'ACTIVE', changeType: 'REACTIVATED' }, + + // Cancelled transitions (can only reactivate) + { from: 'CANCELLED', to: 'ACTIVE', changeType: 'REACTIVATED' }, +]; + +export function isValidTransition( + from: SubscriptionStatus, + to: SubscriptionStatus, + changeType: SubscriptionChangeType +): boolean { + return VALID_TRANSITIONS.some( + (t) => t.from === from && t.to === to && t.changeType === changeType + ); +} + +export function getValidNextStates(current: SubscriptionStatus): SubscriptionStatus[] { + const nextStates = VALID_TRANSITIONS + .filter((t) => t.from === current) + .map((t) => t.to); + return [...new Set(nextStates)]; +} + +export function getRequiredChangeType( + from: SubscriptionStatus, + to: SubscriptionStatus +): SubscriptionChangeType | null { + const transition = VALID_TRANSITIONS.find( + (t) => t.from === from && t.to === to + ); + return transition?.changeType ?? null; +} + +export function canAccessStore(status: SubscriptionStatus): boolean { + return ['TRIAL', 'ACTIVE', 'GRACE_PERIOD', 'PAST_DUE'].includes(status); +} + +export function isReadOnlyAccess(status: SubscriptionStatus): boolean { + return status === 'EXPIRED'; +} + +export function isFullyBlocked(status: SubscriptionStatus): boolean { + return status === 'SUSPENDED'; +} + +export function getRemainingDays(endDate: Date | null): number { + if (!endDate) return 0; + const now = new Date(); + const diff = endDate.getTime() - now.getTime(); + return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24))); +} + +export function isExpiringSoon(endDate: Date | null, thresholdDays = 5): boolean { + const remaining = getRemainingDays(endDate); + return remaining > 0 && remaining <= thresholdDays; +} + +export function getExpiryDate( + status: SubscriptionStatus, + trialEndsAt: Date | null, + currentPeriodEnd: Date | null, + graceEndsAt: Date | null +): Date | null { + switch (status) { + case 'TRIAL': + return trialEndsAt; + case 'ACTIVE': + case 'PAST_DUE': + return currentPeriodEnd; + case 'GRACE_PERIOD': + return graceEndsAt; + default: + return null; + } +} From 688465b7e1117301804f0167168380b2d1d37e29 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 13:58:27 +0600 Subject: [PATCH 04/68] up --- src/lib/subscription/billing-service.ts | 560 +++++++++++++++++++ src/lib/subscription/notification-service.ts | 176 ++++++ 2 files changed, 736 insertions(+) create mode 100644 src/lib/subscription/billing-service.ts create mode 100644 src/lib/subscription/notification-service.ts diff --git a/src/lib/subscription/billing-service.ts b/src/lib/subscription/billing-service.ts new file mode 100644 index 00000000..de7efa43 --- /dev/null +++ b/src/lib/subscription/billing-service.ts @@ -0,0 +1,560 @@ +/** + * Subscription Billing Service + * + * Core business logic for subscription lifecycle management. + * Handles plan changes, invoicing, payment processing, and state transitions. + */ + +import { prisma } from '@/lib/prisma'; +import { isValidTransition, getRemainingDays, getExpiryDate, isExpiringSoon } from './state-machine'; +import { getEffectiveFeatureLimits, getUsageStats } from './feature-enforcer'; +import { getPaymentGateway } from './payment-gateway'; +import type { SubscriptionStatus, SubscriptionChangeType, BillingCycle, SubPaymentStatus } from '@prisma/client'; +import type { SubscriptionWithPlan, SubscriptionDashboardData, PaymentCheckoutRequest, PaymentGatewayResult } from './types'; + +const SUBSCRIPTION_INCLUDE = { + plan: { + select: { + id: true, + name: true, + slug: true, + tier: true, + monthlyPrice: true, + yearlyPrice: true, + maxProducts: true, + maxStaff: true, + storageLimitMb: true, + maxOrders: true, + posEnabled: true, + accountingEnabled: true, + customDomainEnabled: true, + apiAccessEnabled: true, + features: true, + badge: true, + }, + }, +} as const; + +export async function getSubscription(storeId: string): Promise { + return prisma.subscription.findUnique({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); +} + +export async function getDashboardData(storeId: string): Promise { + const subscription = await getSubscription(storeId); + const featureLimits = getEffectiveFeatureLimits(subscription); + const usage = await getUsageStats(storeId); + + const status = subscription?.status ?? 'TRIAL'; + const expiryDate = subscription + ? getExpiryDate( + subscription.status, + subscription.trialEndsAt, + subscription.currentPeriodEnd, + subscription.graceEndsAt + ) + : null; + + return { + subscription, + currentPlan: subscription?.plan.name ?? 'Free', + status: status as SubscriptionStatus, + expiryDate, + remainingDays: getRemainingDays(expiryDate), + isExpiringSoon: isExpiringSoon(expiryDate), + featureLimits, + usage, + }; +} + +export async function createTrialSubscription( + storeId: string, + planId: string +): Promise { + const plan = await prisma.subscriptionPlanModel.findUniqueOrThrow({ + where: { id: planId }, + }); + + const now = new Date(); + const trialEnd = new Date(now.getTime() + plan.trialDays * 24 * 60 * 60 * 1000); + + const subscription = await prisma.subscription.create({ + data: { + storeId, + planId, + status: 'TRIAL', + billingCycle: 'MONTHLY', + currentPrice: 0, + trialStartedAt: now, + trialEndsAt: trialEnd, + autoRenew: true, + }, + include: SUBSCRIPTION_INCLUDE, + }); + + await logSubscriptionChange(subscription.id, { + changeType: 'TRIAL_STARTED', + toStatus: 'TRIAL', + reason: `Trial started for plan: ${plan.name}`, + performedByRole: 'system', + }); + + return subscription; +} + +export async function upgradePlan( + storeId: string, + newPlanId: string, + billingCycle: BillingCycle, + performedBy: string +): Promise { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); + + const newPlan = await prisma.subscriptionPlanModel.findUniqueOrThrow({ + where: { id: newPlanId }, + }); + + const price = billingCycle === 'MONTHLY' ? newPlan.monthlyPrice : newPlan.yearlyPrice; + const now = new Date(); + const periodEnd = new Date( + now.getTime() + (billingCycle === 'MONTHLY' ? 30 : 365) * 24 * 60 * 60 * 1000 + ); + + const updated = await prisma.subscription.update({ + where: { storeId }, + data: { + planId: newPlanId, + billingCycle, + currentPrice: price, + status: 'ACTIVE', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + nextPaymentAt: periodEnd, + scheduledDowngradePlanId: null, + scheduledDowngradeAt: null, + }, + include: SUBSCRIPTION_INCLUDE, + }); + + await logSubscriptionChange(subscription.id, { + changeType: 'UPGRADED', + fromStatus: subscription.status, + toStatus: 'ACTIVE', + fromPlanId: subscription.planId, + toPlanId: newPlanId, + reason: `Upgraded from ${subscription.plan.name} to ${newPlan.name}`, + performedBy, + performedByRole: 'store_owner', + }); + + return updated; +} + +export async function scheduleDowngrade( + storeId: string, + newPlanId: string, + performedBy: string +): Promise { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); + + const newPlan = await prisma.subscriptionPlanModel.findUniqueOrThrow({ + where: { id: newPlanId }, + }); + + const updated = await prisma.subscription.update({ + where: { storeId }, + data: { + scheduledDowngradePlanId: newPlanId, + scheduledDowngradeAt: subscription.currentPeriodEnd, + }, + include: SUBSCRIPTION_INCLUDE, + }); + + await logSubscriptionChange(subscription.id, { + changeType: 'DOWNGRADED', + fromPlanId: subscription.planId, + toPlanId: newPlanId, + reason: `Downgrade to ${newPlan.name} scheduled for ${subscription.currentPeriodEnd?.toISOString()}`, + performedBy, + performedByRole: 'store_owner', + }); + + return updated; +} + +export async function cancelSubscription( + storeId: string, + cancelImmediately: boolean, + reason: string, + performedBy: string +): Promise { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); + + const data: Record = cancelImmediately + ? { status: 'CANCELLED', cancelledAt: new Date(), autoRenew: false } + : { cancelAtPeriodEnd: true, autoRenew: false }; + + const updated = await prisma.subscription.update({ + where: { storeId }, + data, + include: SUBSCRIPTION_INCLUDE, + }); + + await logSubscriptionChange(subscription.id, { + changeType: 'CANCELLED', + fromStatus: subscription.status, + toStatus: cancelImmediately ? 'CANCELLED' : subscription.status, + reason: `Cancelled: ${reason}. ${cancelImmediately ? 'Effective immediately' : 'At period end'}`, + performedBy, + performedByRole: 'store_owner', + }); + + return updated; +} + +export async function extendSubscription( + storeId: string, + days: number, + reason: string, + performedBy: string +): Promise { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); + + const currentEnd = subscription.currentPeriodEnd ?? new Date(); + const newEnd = new Date(currentEnd.getTime() + days * 24 * 60 * 60 * 1000); + + const updated = await prisma.subscription.update({ + where: { storeId }, + data: { + currentPeriodEnd: newEnd, + nextPaymentAt: newEnd, + status: 'ACTIVE', + }, + include: SUBSCRIPTION_INCLUDE, + }); + + await logSubscriptionChange(subscription.id, { + changeType: 'EXTENDED', + fromStatus: subscription.status, + toStatus: 'ACTIVE', + reason: `Extended by ${days} days: ${reason}`, + performedBy, + performedByRole: 'superadmin', + }); + + return updated; +} + +export async function suspendSubscription( + storeId: string, + reason: string, + performedBy: string +): Promise { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); + + const updated = await prisma.subscription.update({ + where: { storeId }, + data: { status: 'SUSPENDED', suspendedAt: new Date(), autoRenew: false }, + include: SUBSCRIPTION_INCLUDE, + }); + + await logSubscriptionChange(subscription.id, { + changeType: 'SUSPENDED', + fromStatus: subscription.status, + toStatus: 'SUSPENDED', + reason, + performedBy, + performedByRole: 'superadmin', + }); + + return updated; +} + +export async function reactivateSubscription( + storeId: string, + reason: string, + performedBy: string +): Promise { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); + + const now = new Date(); + const periodEnd = new Date( + now.getTime() + + (subscription.billingCycle === 'MONTHLY' ? 30 : 365) * 24 * 60 * 60 * 1000 + ); + + const updated = await prisma.subscription.update({ + where: { storeId }, + data: { + status: 'ACTIVE', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + suspendedAt: null, + cancelledAt: null, + autoRenew: true, + failedPaymentCount: 0, + }, + include: SUBSCRIPTION_INCLUDE, + }); + + await logSubscriptionChange(subscription.id, { + changeType: 'REACTIVATED', + fromStatus: subscription.status, + toStatus: 'ACTIVE', + reason, + performedBy, + performedByRole: 'superadmin', + }); + + return updated; +} + +export async function processPaymentCheckout( + storeId: string, + request: PaymentCheckoutRequest +): Promise { + const subscription = await prisma.subscription.findUniqueOrThrow({ + where: { storeId }, + include: SUBSCRIPTION_INCLUDE, + }); + + const plan = await prisma.subscriptionPlanModel.findUniqueOrThrow({ + where: { id: request.planId }, + }); + + const price = request.billingCycle === 'MONTHLY' ? plan.monthlyPrice : plan.yearlyPrice; + const gateway = getPaymentGateway(request.gateway); + const idempotencyKey = `sub_${storeId}_${request.planId}_${Date.now()}`; + + const result = await gateway.createPayment({ + amount: price, + currency: 'BDT', + description: `${plan.name} - ${request.billingCycle} subscription`, + metadata: { + storeId, + planId: request.planId, + subscriptionId: subscription.id, + billingCycle: request.billingCycle, + }, + returnUrl: request.returnUrl, + }); + + if (result.success && result.transactionId) { + await prisma.subPayment.create({ + data: { + subscriptionId: subscription.id, + amount: price, + currency: 'BDT', + status: 'PENDING', + gateway: request.gateway, + gatewayTransactionId: result.transactionId, + idempotencyKey, + gatewayResponse: JSON.stringify(result.rawResponse), + }, + }); + } + + return { + success: result.success, + transactionId: result.transactionId, + checkoutUrl: result.checkoutUrl, + error: result.error, + }; +} + +export async function handlePaymentWebhook( + transactionId: string, + status: SubPaymentStatus, + gateway: string +): Promise { + const payment = await prisma.subPayment.findFirst({ + where: { gatewayTransactionId: transactionId }, + include: { subscription: { include: SUBSCRIPTION_INCLUDE } }, + }); + + if (!payment) { + console.warn(`[billing] Payment not found for transaction: ${transactionId}`); + return; + } + + // Idempotency: skip if already processed + if (payment.status === status) return; + + const now = new Date(); + + if (status === 'SUCCESS') { + const subscription = payment.subscription; + const periodEnd = new Date( + now.getTime() + + (subscription.billingCycle === 'MONTHLY' ? 30 : 365) * 24 * 60 * 60 * 1000 + ); + + await prisma.$transaction([ + prisma.subPayment.update({ + where: { id: payment.id }, + data: { status: 'SUCCESS', paidAt: now }, + }), + prisma.subscription.update({ + where: { id: subscription.id }, + data: { + status: 'ACTIVE', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + lastPaymentAt: now, + nextPaymentAt: periodEnd, + failedPaymentCount: 0, + }, + }), + ]); + + await logSubscriptionChange(subscription.id, { + changeType: 'PAYMENT_SUCCESS', + fromStatus: subscription.status, + toStatus: 'ACTIVE', + reason: `Payment succeeded via ${gateway}: ${transactionId}`, + performedByRole: 'system', + }); + + // Create invoice + await createInvoiceForPayment(subscription.id, payment.amount, now, periodEnd); + } else if (status === 'FAILED') { + const newFailCount = payment.subscription.failedPaymentCount + 1; + const nextRetry = new Date(now.getTime() + getRetryDelay(payment.retryCount) * 1000); + + await prisma.$transaction([ + prisma.subPayment.update({ + where: { id: payment.id }, + data: { + status: 'FAILED', + failedAt: now, + retryCount: { increment: 1 }, + nextRetryAt: payment.retryCount < payment.maxRetries ? nextRetry : null, + lastError: 'Payment failed', + }, + }), + prisma.subscription.update({ + where: { id: payment.subscription.id }, + data: { failedPaymentCount: newFailCount }, + }), + ]); + + await logSubscriptionChange(payment.subscription.id, { + changeType: 'PAYMENT_FAILED', + reason: `Payment failed via ${gateway}: ${transactionId} (attempt ${payment.retryCount + 1})`, + performedByRole: 'system', + }); + } +} + +// Exponential backoff: 1h, 6h, 24h +function getRetryDelay(retryCount: number): number { + const delays = [3600, 21600, 86400]; + return delays[Math.min(retryCount, delays.length - 1)]; +} + +async function createInvoiceForPayment( + subscriptionId: string, + amount: number, + periodStart: Date, + periodEnd: Date +): Promise { + const invoiceNumber = `INV-${Date.now()}-${Math.random().toString(36).slice(2, 7).toUpperCase()}`; + + await prisma.invoice.create({ + data: { + subscriptionId, + invoiceNumber, + status: 'paid', + subtotal: amount, + totalAmount: amount, + periodStart, + periodEnd, + issuedAt: new Date(), + paidAt: new Date(), + items: { + create: { + description: 'Subscription payment', + quantity: 1, + unitPrice: amount, + totalPrice: amount, + }, + }, + }, + }); +} + +async function logSubscriptionChange( + subscriptionId: string, + data: { + changeType: SubscriptionChangeType; + fromStatus?: SubscriptionStatus; + toStatus?: SubscriptionStatus; + fromPlanId?: string; + toPlanId?: string; + reason?: string; + performedBy?: string; + performedByRole?: string; + metadata?: Record; + } +): Promise { + await prisma.subscriptionLog.create({ + data: { + subscriptionId, + changeType: data.changeType, + fromStatus: data.fromStatus, + toStatus: data.toStatus, + fromPlanId: data.fromPlanId, + toPlanId: data.toPlanId, + reason: data.reason, + performedBy: data.performedBy, + performedByRole: data.performedByRole, + metadata: data.metadata ? JSON.stringify(data.metadata) : undefined, + }, + }); +} + +export async function getBillingHistory( + subscriptionId: string, + page = 1, + limit = 20 +) { + const [invoices, total] = await Promise.all([ + prisma.invoice.findMany({ + where: { subscriptionId }, + include: { items: true, payments: { select: { gateway: true, status: true, paidAt: true } } }, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.invoice.count({ where: { subscriptionId } }), + ]); + + return { invoices, total, page, limit, totalPages: Math.ceil(total / limit) }; +} + +export async function getAvailablePlans() { + return prisma.subscriptionPlanModel.findMany({ + where: { isActive: true, isPublic: true, deletedAt: null }, + orderBy: { sortOrder: 'asc' }, + }); +} diff --git a/src/lib/subscription/notification-service.ts b/src/lib/subscription/notification-service.ts new file mode 100644 index 00000000..36ad6ab3 --- /dev/null +++ b/src/lib/subscription/notification-service.ts @@ -0,0 +1,176 @@ +/** + * Subscription Notification Service + * + * Sends automated notifications for subscription lifecycle events. + * Supports email, in-app, and SMS (abstract) channels. + */ + +import { prisma } from '@/lib/prisma'; +import type { NotificationType } from '@prisma/client'; + +interface NotificationPayload { + userId: string; + type: NotificationType; + title: string; + message: string; + actionUrl?: string; + actionLabel?: string; + data?: Record; +} + +// Channel abstractions for extensibility +interface NotificationChannel { + send(payload: NotificationPayload): Promise; +} + +class InAppChannel implements NotificationChannel { + async send(payload: NotificationPayload): Promise { + await prisma.notification.create({ + data: { + userId: payload.userId, + type: payload.type, + title: payload.title, + message: payload.message, + actionUrl: payload.actionUrl, + actionLabel: payload.actionLabel, + data: payload.data ? JSON.stringify(payload.data) : undefined, + }, + }); + } +} + +class EmailChannel implements NotificationChannel { + async send(payload: NotificationPayload): Promise { + // In production: integrate with Resend/SendGrid/SES + console.log(`[email-notification] To: ${payload.userId}, Subject: ${payload.title}`); + console.log(`[email-notification] Body: ${payload.message}`); + } +} + +class SmsChannel implements NotificationChannel { + async send(payload: NotificationPayload): Promise { + // Abstract SMS — integrate with Twilio/Vonage/etc. + console.log(`[sms-notification] To: ${payload.userId}, Message: ${payload.message}`); + } +} + +const channels: NotificationChannel[] = [ + new InAppChannel(), + new EmailChannel(), +]; + +async function sendNotification(payload: NotificationPayload): Promise { + await Promise.allSettled( + channels.map((channel) => channel.send(payload)) + ); +} + +export async function notifyExpiryWarning( + userId: string, + storeName: string, + daysRemaining: number, + planName: string +): Promise { + const urgency = daysRemaining <= 1 ? 'urgent' : daysRemaining <= 2 ? 'important' : 'reminder'; + + await sendNotification({ + userId, + type: 'SYSTEM_ANNOUNCEMENT', + title: `Subscription ${urgency === 'urgent' ? 'expires today' : `expiring in ${daysRemaining} days`}`, + message: `Your ${planName} plan for "${storeName}" ${ + daysRemaining === 0 + ? 'expires today. Renew now to avoid service interruption.' + : `will expire in ${daysRemaining} day${daysRemaining > 1 ? 's' : ''}. Renew to continue uninterrupted access.` + }`, + actionUrl: '/settings/billing', + actionLabel: 'Renew Now', + data: { daysRemaining, planName, storeName, urgency }, + }); +} + +export async function notifyGracePeriod( + userId: string, + storeName: string, + graceEndDate: Date +): Promise { + const daysLeft = Math.ceil( + (graceEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + + await sendNotification({ + userId, + type: 'SYSTEM_ANNOUNCEMENT', + title: 'Grace Period Active - Action Required', + message: `Your subscription for "${storeName}" has entered a grace period. You have ${daysLeft} day${daysLeft > 1 ? 's' : ''} to make a payment before your store becomes read-only.`, + actionUrl: '/settings/billing', + actionLabel: 'Make Payment', + data: { graceEndDate: graceEndDate.toISOString(), daysLeft }, + }); +} + +export async function notifyPaymentSuccess( + userId: string, + amount: number, + planName: string, + nextBillingDate: Date +): Promise { + await sendNotification({ + userId, + type: 'SYSTEM_ANNOUNCEMENT', + title: 'Payment Successful', + message: `Your payment of ৳${amount.toLocaleString()} for the ${planName} plan was successful. Next billing date: ${nextBillingDate.toLocaleDateString()}.`, + actionUrl: '/settings/billing', + actionLabel: 'View Receipt', + data: { amount, planName, nextBillingDate: nextBillingDate.toISOString() }, + }); +} + +export async function notifyPaymentFailure( + userId: string, + amount: number, + planName: string, + retryDate: Date | null +): Promise { + await sendNotification({ + userId, + type: 'SYSTEM_ANNOUNCEMENT', + title: 'Payment Failed', + message: `Your payment of ৳${amount.toLocaleString()} for the ${planName} plan failed. ${ + retryDate + ? `We will retry on ${retryDate.toLocaleDateString()}.` + : 'Please update your payment method to avoid service interruption.' + }`, + actionUrl: '/settings/billing', + actionLabel: 'Update Payment', + data: { amount, planName, retryDate: retryDate?.toISOString() }, + }); +} + +export async function notifySubscriptionExpired( + userId: string, + storeName: string +): Promise { + await sendNotification({ + userId, + type: 'SYSTEM_ANNOUNCEMENT', + title: 'Subscription Expired', + message: `Your subscription for "${storeName}" has expired. Your store is now in read-only mode. Renew your subscription to regain full access.`, + actionUrl: '/settings/billing', + actionLabel: 'Renew Subscription', + data: { storeName }, + }); +} + +export async function notifySubscriptionSuspended( + userId: string, + storeName: string, + reason: string +): Promise { + await sendNotification({ + userId, + type: 'ACCOUNT_SUSPENDED', + title: 'Store Suspended', + message: `Your store "${storeName}" has been suspended. Reason: ${reason}. Contact support for assistance.`, + data: { storeName, reason }, + }); +} From ecd08bf34bf5ba7f15e68243d2ead291df8feb56 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 13:59:04 +0600 Subject: [PATCH 05/68] Add subscription analytics and export helpers Introduce a new analytics module for subscriptions using Prisma. Adds getRevenueAnalytics (MRR/ARR, revenue by month, churn and failure rates, upgrade/downgrade counts), getSubscriptionsByStatus, getSubscriptionsForAdmin (filtering, search, pagination), and CSV export helpers exportSubscriptionReport and exportPaymentReport. Includes an internal getMonthlyRevenue helper to aggregate monthly revenue. Designed for superadmin dashboard reporting and optimized with aggregated DB queries. --- src/lib/subscription/analytics.ts | 229 ++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 src/lib/subscription/analytics.ts diff --git a/src/lib/subscription/analytics.ts b/src/lib/subscription/analytics.ts new file mode 100644 index 00000000..441565cb --- /dev/null +++ b/src/lib/subscription/analytics.ts @@ -0,0 +1,229 @@ +/** + * Subscription Analytics + * + * Provides revenue metrics and business analytics for the superadmin dashboard. + * All queries are optimized with proper indexing and aggregation. + */ + +import { prisma } from '@/lib/prisma'; +import type { RevenueAnalytics } from './types'; + +export async function getRevenueAnalytics(): Promise { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const [ + activeSubscriptions, + trialUsers, + expiredUsers, + suspendedUsers, + monthlyPayments, + failedPayments, + totalPayments, + upgrades, + downgrades, + revenueByMonth, + cancelledThisMonth, + ] = await Promise.all([ + prisma.subscription.count({ where: { status: 'ACTIVE' } }), + prisma.subscription.count({ where: { status: 'TRIAL' } }), + prisma.subscription.count({ where: { status: 'EXPIRED' } }), + prisma.subscription.count({ where: { status: 'SUSPENDED' } }), + prisma.subPayment.aggregate({ + where: { status: 'SUCCESS', paidAt: { gte: thirtyDaysAgo } }, + _sum: { amount: true }, + }), + prisma.subPayment.count({ + where: { status: 'FAILED', createdAt: { gte: thirtyDaysAgo } }, + }), + prisma.subPayment.count({ + where: { createdAt: { gte: thirtyDaysAgo } }, + }), + prisma.subscriptionLog.count({ + where: { changeType: 'UPGRADED', createdAt: { gte: thirtyDaysAgo } }, + }), + prisma.subscriptionLog.count({ + where: { changeType: 'DOWNGRADED', createdAt: { gte: thirtyDaysAgo } }, + }), + getMonthlyRevenue(12), + prisma.subscription.count({ + where: { status: 'CANCELLED', cancelledAt: { gte: thirtyDaysAgo } }, + }), + ]); + + const mrr = monthlyPayments._sum.amount ?? 0; + const arr = mrr * 12; + const paymentFailureRate = totalPayments > 0 ? (failedPayments / totalPayments) * 100 : 0; + const totalActiveStart = activeSubscriptions + cancelledThisMonth; + const churnRate = totalActiveStart > 0 ? (cancelledThisMonth / totalActiveStart) * 100 : 0; + + return { + mrr, + arr, + activeSubscriptions, + trialUsers, + expiredUsers, + suspendedUsers, + revenueByMonth, + paymentFailureRate: Math.round(paymentFailureRate * 100) / 100, + upgradeCount: upgrades, + downgradeCount: downgrades, + churnRate: Math.round(churnRate * 100) / 100, + }; +} + +async function getMonthlyRevenue(months: number) { + const results: { month: string; revenue: number }[] = []; + const now = new Date(); + + for (let i = months - 1; i >= 0; i--) { + const start = new Date(now.getFullYear(), now.getMonth() - i, 1); + const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 0, 23, 59, 59); + + const revenue = await prisma.subPayment.aggregate({ + where: { + status: 'SUCCESS', + paidAt: { gte: start, lte: end }, + }, + _sum: { amount: true }, + }); + + results.push({ + month: start.toISOString().slice(0, 7), + revenue: revenue._sum.amount ?? 0, + }); + } + + return results; +} + +export async function getSubscriptionsByStatus() { + const [trial, active, gracePeriod, expired, suspended, cancelled] = await Promise.all([ + prisma.subscription.count({ where: { status: 'TRIAL' } }), + prisma.subscription.count({ where: { status: 'ACTIVE' } }), + prisma.subscription.count({ where: { status: 'GRACE_PERIOD' } }), + prisma.subscription.count({ where: { status: 'EXPIRED' } }), + prisma.subscription.count({ where: { status: 'SUSPENDED' } }), + prisma.subscription.count({ where: { status: 'CANCELLED' } }), + ]); + + return { trial, active, gracePeriod, expired, suspended, cancelled }; +} + +export async function getSubscriptionsForAdmin(params: { + status?: string; + planId?: string; + search?: string; + page?: number; + limit?: number; +}) { + const { status, planId, search, page = 1, limit = 20 } = params; + + const where: Record = {}; + if (status) where.status = status; + if (planId) where.planId = planId; + if (search) { + where.store = { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + ], + }; + } + + const [subscriptions, total] = await Promise.all([ + prisma.subscription.findMany({ + where, + include: { + plan: { select: { name: true, slug: true, tier: true } }, + store: { select: { id: true, name: true, slug: true, email: true } }, + }, + orderBy: { updatedAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + prisma.subscription.count({ where }), + ]); + + return { subscriptions, total, page, limit, totalPages: Math.ceil(total / limit) }; +} + +export async function exportSubscriptionReport() { + const subscriptions = await prisma.subscription.findMany({ + include: { + plan: { select: { name: true, tier: true, monthlyPrice: true, yearlyPrice: true } }, + store: { select: { name: true, slug: true, email: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const headers = [ + 'Store Name', + 'Store Email', + 'Plan', + 'Tier', + 'Status', + 'Billing Cycle', + 'Current Price', + 'Period Start', + 'Period End', + 'Auto Renew', + 'Created At', + ]; + + const rows = subscriptions.map((sub) => [ + sub.store.name, + sub.store.email, + sub.plan.name, + sub.plan.tier, + sub.status, + sub.billingCycle, + sub.currentPrice.toString(), + sub.currentPeriodStart?.toISOString() ?? '', + sub.currentPeriodEnd?.toISOString() ?? '', + sub.autoRenew ? 'Yes' : 'No', + sub.createdAt.toISOString(), + ]); + + return [headers, ...rows].map((row) => row.join(',')).join('\n'); +} + +export async function exportPaymentReport() { + const payments = await prisma.subPayment.findMany({ + include: { + subscription: { + include: { + store: { select: { name: true, email: true } }, + plan: { select: { name: true } }, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const headers = [ + 'Store Name', + 'Plan', + 'Amount', + 'Currency', + 'Status', + 'Gateway', + 'Transaction ID', + 'Paid At', + 'Created At', + ]; + + const rows = payments.map((p) => [ + p.subscription.store.name, + p.subscription.plan.name, + p.amount.toString(), + p.currency, + p.status, + p.gateway, + p.gatewayTransactionId ?? '', + p.paidAt?.toISOString() ?? '', + p.createdAt.toISOString(), + ]); + + return [headers, ...rows].map((row) => row.join(',')).join('\n'); +} From 37167e793bdf233938f69c28ae319cef6ce0c99a Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 13:59:42 +0600 Subject: [PATCH 06/68] up --- src/lib/subscription/cron-jobs.ts | 418 ++++++++++++++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 src/lib/subscription/cron-jobs.ts diff --git a/src/lib/subscription/cron-jobs.ts b/src/lib/subscription/cron-jobs.ts new file mode 100644 index 00000000..242baeec --- /dev/null +++ b/src/lib/subscription/cron-jobs.ts @@ -0,0 +1,418 @@ +/** + * Subscription Background Jobs + * + * Cron-style jobs for subscription lifecycle management. + * These should be called from an external scheduler (e.g., Vercel Cron, node-cron, BullMQ). + */ + +import { prisma } from '@/lib/prisma'; +import { getPaymentGateway } from './payment-gateway'; +import { + notifyExpiryWarning, + notifyGracePeriod, + notifySubscriptionExpired, +} from './notification-service'; + +/** + * Check and process expired trials. + * Runs daily - converts expired trials to ACTIVE (if auto-renew) or EXPIRED. + */ +export async function processExpiredTrials(): Promise<{ processed: number }> { + const now = new Date(); + + const expiredTrials = await prisma.subscription.findMany({ + where: { + status: 'TRIAL', + trialEndsAt: { lte: now }, + }, + include: { + plan: true, + store: { + select: { id: true, name: true, organizationId: true }, + include: { + organization: { + include: { memberships: { where: { role: 'OWNER' }, select: { userId: true } } }, + }, + }, + } as never, + }, + }); + + for (const sub of expiredTrials) { + if (sub.autoRenew) { + const periodEnd = new Date( + now.getTime() + + (sub.billingCycle === 'MONTHLY' ? 30 : 365) * 24 * 60 * 60 * 1000 + ); + + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { + status: 'ACTIVE', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + nextPaymentAt: periodEnd, + }, + }), + prisma.subscriptionLog.create({ + data: { + subscriptionId: sub.id, + changeType: 'TRIAL_CONVERTED', + fromStatus: 'TRIAL', + toStatus: 'ACTIVE', + reason: 'Trial converted automatically (auto-renew enabled)', + performedByRole: 'system', + }, + }), + ]); + } else { + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { status: 'EXPIRED' }, + }), + prisma.subscriptionLog.create({ + data: { + subscriptionId: sub.id, + changeType: 'EXPIRED', + fromStatus: 'TRIAL', + toStatus: 'EXPIRED', + reason: 'Trial expired without auto-renew', + performedByRole: 'system', + }, + }), + ]); + } + } + + return { processed: expiredTrials.length }; +} + +/** + * Check active subscriptions nearing expiry and enter grace period. + * Runs daily. + */ +export async function processExpiringSubscriptions(): Promise<{ processed: number }> { + const now = new Date(); + + const expiring = await prisma.subscription.findMany({ + where: { + status: 'ACTIVE', + currentPeriodEnd: { lte: now }, + autoRenew: false, + }, + include: { + plan: { select: { gracePeriodDays: true, name: true } }, + }, + }); + + for (const sub of expiring) { + const graceEnd = new Date( + now.getTime() + sub.plan.gracePeriodDays * 24 * 60 * 60 * 1000 + ); + + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { status: 'GRACE_PERIOD', graceEndsAt: graceEnd }, + }), + prisma.subscriptionLog.create({ + data: { + subscriptionId: sub.id, + changeType: 'GRACE_ENTERED', + fromStatus: 'ACTIVE', + toStatus: 'GRACE_PERIOD', + reason: `Subscription period ended. Grace period: ${sub.plan.gracePeriodDays} days.`, + performedByRole: 'system', + }, + }), + ]); + } + + return { processed: expiring.length }; +} + +/** + * Process grace period expirations. + * Runs daily. + */ +export async function processGracePeriodExpiry(): Promise<{ processed: number }> { + const now = new Date(); + + const expired = await prisma.subscription.findMany({ + where: { + status: 'GRACE_PERIOD', + graceEndsAt: { lte: now }, + }, + }); + + for (const sub of expired) { + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { status: 'EXPIRED' }, + }), + prisma.subscriptionLog.create({ + data: { + subscriptionId: sub.id, + changeType: 'EXPIRED', + fromStatus: 'GRACE_PERIOD', + toStatus: 'EXPIRED', + reason: 'Grace period ended without payment', + performedByRole: 'system', + }, + }), + ]); + } + + return { processed: expired.length }; +} + +/** + * Send expiry reminder notifications. + * Runs daily - sends reminders at 5 days, 2 days, and 0 days before expiry. + */ +export async function sendExpiryReminders(): Promise<{ sent: number }> { + const now = new Date(); + let sentCount = 0; + + for (const daysBefore of [5, 2, 0]) { + const targetDate = new Date(now.getTime() + daysBefore * 24 * 60 * 60 * 1000); + const startOfDay = new Date(targetDate); + startOfDay.setHours(0, 0, 0, 0); + const endOfDay = new Date(targetDate); + endOfDay.setHours(23, 59, 59, 999); + + const subsExpiring = await prisma.subscription.findMany({ + where: { + OR: [ + { status: 'TRIAL', trialEndsAt: { gte: startOfDay, lte: endOfDay } }, + { status: 'ACTIVE', currentPeriodEnd: { gte: startOfDay, lte: endOfDay } }, + ], + }, + include: { + store: { + select: { + name: true, + organization: { + select: { + memberships: { + where: { role: { in: ['OWNER', 'ADMIN'] } }, + select: { userId: true }, + }, + }, + }, + }, + }, + plan: { select: { name: true } }, + }, + }); + + for (const sub of subsExpiring) { + const ownerIds = sub.store.organization.memberships.map((m) => m.userId); + for (const userId of ownerIds) { + await notifyExpiryWarning(userId, sub.store.name, daysBefore, sub.plan.name); + sentCount++; + } + } + } + + return { sent: sentCount }; +} + +/** + * Retry failed payments with exponential backoff. + * Runs every hour. + */ +export async function retryFailedPayments(): Promise<{ retried: number; succeeded: number }> { + const now = new Date(); + + const failedPayments = await prisma.subPayment.findMany({ + where: { + status: 'FAILED', + retryCount: { lt: 3 }, + nextRetryAt: { lte: now }, + }, + include: { + subscription: { + include: { plan: true }, + }, + }, + }); + + let succeeded = 0; + + for (const payment of failedPayments) { + try { + const gateway = getPaymentGateway(payment.gateway); + const result = await gateway.getPaymentStatus(payment.gatewayTransactionId ?? ''); + + if (result.status === 'success') { + const periodEnd = new Date( + now.getTime() + + (payment.subscription.billingCycle === 'MONTHLY' ? 30 : 365) * + 24 * 60 * 60 * 1000 + ); + + await prisma.$transaction([ + prisma.subPayment.update({ + where: { id: payment.id }, + data: { status: 'SUCCESS', paidAt: now }, + }), + prisma.subscription.update({ + where: { id: payment.subscriptionId }, + data: { + status: 'ACTIVE', + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + lastPaymentAt: now, + failedPaymentCount: 0, + }, + }), + ]); + succeeded++; + } else { + const nextRetry = new Date(now.getTime() + getRetryDelay(payment.retryCount) * 1000); + + await prisma.subPayment.update({ + where: { id: payment.id }, + data: { + retryCount: { increment: 1 }, + nextRetryAt: payment.retryCount + 1 < payment.maxRetries ? nextRetry : null, + lastError: `Retry ${payment.retryCount + 1} failed`, + }, + }); + } + } catch (error) { + console.error(`[cron] Payment retry failed for ${payment.id}:`, error); + } + } + + return { retried: failedPayments.length, succeeded }; +} + +function getRetryDelay(retryCount: number): number { + const delays = [3600, 21600, 86400]; + return delays[Math.min(retryCount, delays.length - 1)]; +} + +/** + * Process scheduled downgrades. + * Runs daily at billing cycle boundaries. + */ +export async function processScheduledDowngrades(): Promise<{ processed: number }> { + const now = new Date(); + + const pendingDowngrades = await prisma.subscription.findMany({ + where: { + scheduledDowngradePlanId: { not: null }, + scheduledDowngradeAt: { lte: now }, + }, + include: { + plan: { select: { name: true } }, + scheduledDowngradePlan: true, + }, + }); + + for (const sub of pendingDowngrades) { + if (!sub.scheduledDowngradePlan) continue; + + const newPrice = + sub.billingCycle === 'MONTHLY' + ? sub.scheduledDowngradePlan.monthlyPrice + : sub.scheduledDowngradePlan.yearlyPrice; + + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { + planId: sub.scheduledDowngradePlanId!, + currentPrice: newPrice, + scheduledDowngradePlanId: null, + scheduledDowngradeAt: null, + }, + }), + prisma.subscriptionLog.create({ + data: { + subscriptionId: sub.id, + changeType: 'DOWNGRADED', + fromPlanId: sub.planId, + toPlanId: sub.scheduledDowngradePlanId!, + reason: `Scheduled downgrade from ${sub.plan.name} to ${sub.scheduledDowngradePlan.name}`, + performedByRole: 'system', + }, + }), + ]); + } + + return { processed: pendingDowngrades.length }; +} + +/** + * Auto-renew active subscriptions. + * Runs daily. + */ +export async function processAutoRenewals(): Promise<{ renewed: number; failed: number }> { + const now = new Date(); + + const expiring = await prisma.subscription.findMany({ + where: { + status: 'ACTIVE', + autoRenew: true, + currentPeriodEnd: { lte: now }, + }, + include: { plan: true }, + }); + + let renewed = 0; + let failed = 0; + + for (const sub of expiring) { + const price = + sub.billingCycle === 'MONTHLY' ? sub.plan.monthlyPrice : sub.plan.yearlyPrice; + const periodEnd = new Date( + now.getTime() + (sub.billingCycle === 'MONTHLY' ? 30 : 365) * 24 * 60 * 60 * 1000 + ); + + // Free plans auto-renew without payment + if (price === 0) { + await prisma.$transaction([ + prisma.subscription.update({ + where: { id: sub.id }, + data: { + currentPeriodStart: now, + currentPeriodEnd: periodEnd, + nextPaymentAt: periodEnd, + }, + }), + prisma.subscriptionLog.create({ + data: { + subscriptionId: sub.id, + changeType: 'RENEWED', + fromStatus: 'ACTIVE', + toStatus: 'ACTIVE', + reason: 'Free plan auto-renewed', + performedByRole: 'system', + }, + }), + ]); + renewed++; + } else { + // Paid plans: create pending payment + await prisma.subPayment.create({ + data: { + subscriptionId: sub.id, + amount: sub.priceOverride ?? price, + currency: 'BDT', + status: 'PENDING', + gateway: 'manual', + idempotencyKey: `renew_${sub.id}_${now.getTime()}`, + }, + }); + renewed++; + } + } + + return { renewed, failed }; +} From ad922d24a2c8606cbe0a867c6c1e83b9d9ac3dfc Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:00:35 +0600 Subject: [PATCH 07/68] up --- src/lib/subscription/index.ts | 95 ++++++++++++++ src/lib/subscription/middleware.ts | 196 +++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 src/lib/subscription/index.ts create mode 100644 src/lib/subscription/middleware.ts diff --git a/src/lib/subscription/index.ts b/src/lib/subscription/index.ts new file mode 100644 index 00000000..92a5f513 --- /dev/null +++ b/src/lib/subscription/index.ts @@ -0,0 +1,95 @@ +/** + * Subscription System - Public API + * + * Barrel export for the subscription management system. + */ + +export { + isValidTransition, + getValidNextStates, + canAccessStore, + isReadOnlyAccess, + isFullyBlocked, + getRemainingDays, + isExpiringSoon, + getExpiryDate, +} from './state-machine'; + +export { + getEffectiveFeatureLimits, + canCreateProduct, + canAddStaff, + canCreateOrder, + canUseFeature, + getUsageStats, +} from './feature-enforcer'; + +export { + getSubscription, + getDashboardData, + createTrialSubscription, + upgradePlan, + scheduleDowngrade, + cancelSubscription, + extendSubscription, + suspendSubscription, + reactivateSubscription, + processPaymentCheckout, + handlePaymentWebhook, + getBillingHistory, + getAvailablePlans, +} from './billing-service'; + +export { + getPaymentGateway, + getAvailableGateways, + registerGateway, +} from './payment-gateway'; +export type { + PaymentGateway as PaymentGatewayInterface, + PaymentResult, + CreatePaymentIntent, +} from './payment-gateway'; + +export { + notifyExpiryWarning, + notifyGracePeriod, + notifyPaymentSuccess, + notifyPaymentFailure, + notifySubscriptionExpired, + notifySubscriptionSuspended, +} from './notification-service'; + +export { + getRevenueAnalytics, + getSubscriptionsByStatus, + getSubscriptionsForAdmin, + exportSubscriptionReport, + exportPaymentReport, +} from './analytics'; + +export { + processExpiredTrials, + processExpiringSubscriptions, + processGracePeriodExpiry, + sendExpiryReminders, + retryFailedPayments, + processScheduledDowngrades, + processAutoRenewals, +} from './cron-jobs'; + +export { + checkSubscriptionAccess, + withSubscriptionCheck, + withFeatureGate, +} from './middleware'; + +export type { + FeatureLimits, + SubscriptionWithPlan, + SubscriptionDashboardData, + PaymentCheckoutRequest, + PaymentGatewayResult, + RevenueAnalytics, + SubscriptionReminderConfig, +} from './types'; diff --git a/src/lib/subscription/middleware.ts b/src/lib/subscription/middleware.ts new file mode 100644 index 00000000..dbd1cb39 --- /dev/null +++ b/src/lib/subscription/middleware.ts @@ -0,0 +1,196 @@ +/** + * Subscription Enforcement Middleware + * + * Provides API middleware to enforce subscription status and feature limits. + * Blocks expired/suspended stores from write operations while allowing read-only access. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/prisma'; +import { getEffectiveFeatureLimits } from './feature-enforcer'; +import { canAccessStore, isReadOnlyAccess, isFullyBlocked } from './state-machine'; +import type { SubscriptionWithPlan } from './types'; + +interface SubscriptionCheckResult { + allowed: boolean; + readOnly: boolean; + blocked: boolean; + message: string; + subscription: SubscriptionWithPlan | null; +} + +export async function checkSubscriptionAccess( + storeId: string +): Promise { + const subscription = await prisma.subscription.findUnique({ + where: { storeId }, + include: { + plan: { + select: { + id: true, + name: true, + slug: true, + tier: true, + monthlyPrice: true, + yearlyPrice: true, + maxProducts: true, + maxStaff: true, + storageLimitMb: true, + maxOrders: true, + posEnabled: true, + accountingEnabled: true, + customDomainEnabled: true, + apiAccessEnabled: true, + features: true, + badge: true, + }, + }, + }, + }); + + if (!subscription) { + return { + allowed: true, + readOnly: false, + blocked: false, + message: 'No subscription found — free tier access', + subscription: null, + }; + } + + const status = subscription.status; + + if (isFullyBlocked(status)) { + return { + allowed: false, + readOnly: false, + blocked: true, + message: 'Store is suspended. Contact support for assistance.', + subscription, + }; + } + + if (isReadOnlyAccess(status)) { + return { + allowed: true, + readOnly: true, + blocked: false, + message: 'Subscription expired. Store is in read-only mode. Renew to regain full access.', + subscription, + }; + } + + return { + allowed: canAccessStore(status), + readOnly: false, + blocked: false, + message: 'Active subscription', + subscription, + }; +} + +/** + * API route middleware wrapper that enforces subscription status. + * Blocks write operations for expired subscriptions (read-only mode). + * Blocks all access for suspended stores. + */ +export function withSubscriptionCheck( + handler: (request: NextRequest, context: { storeId: string; subscription: SubscriptionWithPlan | null }) => Promise, + options: { allowReadOnly?: boolean } = {} +) { + return async (request: NextRequest) => { + const storeId = request.headers.get('x-store-id'); + if (!storeId) { + return NextResponse.json( + { error: 'Store ID required' }, + { status: 400 } + ); + } + + const check = await checkSubscriptionAccess(storeId); + + if (check.blocked) { + return NextResponse.json( + { error: check.message, code: 'STORE_SUSPENDED' }, + { status: 403 } + ); + } + + if (check.readOnly && !options.allowReadOnly) { + const method = request.method; + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) { + return NextResponse.json( + { error: check.message, code: 'SUBSCRIPTION_EXPIRED' }, + { status: 402 } + ); + } + } + + if (!check.allowed) { + return NextResponse.json( + { error: 'Subscription required', code: 'NO_SUBSCRIPTION' }, + { status: 402 } + ); + } + + return handler(request, { storeId, subscription: check.subscription }); + }; +} + +/** + * Feature-gated middleware for specific capabilities. + * Returns 402 if the feature is not available on the current plan. + */ +export function withFeatureGate( + feature: 'posEnabled' | 'accountingEnabled' | 'customDomainEnabled' | 'apiAccessEnabled', + handler: (request: NextRequest) => Promise +) { + return async (request: NextRequest) => { + const storeId = request.headers.get('x-store-id'); + if (!storeId) { + return NextResponse.json({ error: 'Store ID required' }, { status: 400 }); + } + + const subscription = await prisma.subscription.findUnique({ + where: { storeId }, + include: { + plan: { + select: { + id: true, + name: true, + slug: true, + tier: true, + monthlyPrice: true, + yearlyPrice: true, + maxProducts: true, + maxStaff: true, + storageLimitMb: true, + maxOrders: true, + posEnabled: true, + accountingEnabled: true, + customDomainEnabled: true, + apiAccessEnabled: true, + features: true, + badge: true, + }, + }, + }, + }); + + const limits = getEffectiveFeatureLimits(subscription); + + if (!limits[feature]) { + return NextResponse.json( + { + error: `This feature requires a plan upgrade`, + code: 'FEATURE_NOT_AVAILABLE', + feature, + currentPlan: subscription?.plan.name ?? 'Free', + }, + { status: 402 } + ); + } + + return handler(request); + }; +} From d9a78347f6d7d684d01becd2c4bb37a930f88125 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:00:51 +0600 Subject: [PATCH 08/68] up --- src/app/api/subscriptions/current/route.ts | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/app/api/subscriptions/current/route.ts diff --git a/src/app/api/subscriptions/current/route.ts b/src/app/api/subscriptions/current/route.ts new file mode 100644 index 00000000..99e89e8c --- /dev/null +++ b/src/app/api/subscriptions/current/route.ts @@ -0,0 +1,31 @@ +/** + * GET /api/subscriptions/current + * Returns the current subscription dashboard data for the authenticated store. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { getDashboardData } from '@/lib/subscription'; + +export async function GET(_request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const membership = await prisma.membership.findFirst({ + where: { userId: session.user.id, role: { in: ['OWNER', 'ADMIN'] } }, + include: { organization: { include: { store: { select: { id: true } } } } }, + }); + + if (!membership?.organization?.store) { + return NextResponse.json({ error: 'No store found' }, { status: 404 }); + } + + const storeId = membership.organization.store.id; + const data = await getDashboardData(storeId); + + return NextResponse.json({ data }); +} From dcf9164bc7312e29868319ffc55dfb8181d9e8dc Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:03:21 +0600 Subject: [PATCH 09/68] up --- package-lock.json | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 64af1b0f..28ddc383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -258,6 +258,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -612,6 +613,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -653,6 +655,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -680,6 +683,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -2448,6 +2452,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -2617,6 +2622,7 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -2653,6 +2659,7 @@ "integrity": "sha512-QXFT+N/bva/QI2qoXmjBzL7D6aliPffIwP+81AdTGq0FXDoLxLkWivGMawG8iM5B9BKfxLIXxfWWAF6wbuJU6g==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18.18" }, @@ -5527,6 +5534,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -5842,6 +5850,7 @@ "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5882,6 +5891,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5892,6 +5902,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5959,6 +5970,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -6712,6 +6724,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7048,6 +7061,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -7183,6 +7197,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7966,7 +7981,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -8303,6 +8319,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8488,6 +8505,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -10728,6 +10746,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -10893,6 +10912,7 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", "license": "MIT-0", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -11310,6 +11330,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -11461,6 +11482,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -11560,6 +11582,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -11597,6 +11620,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.0", "@prisma/engines": "6.19.0" @@ -11935,6 +11959,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11975,6 +12000,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11987,6 +12013,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -13159,6 +13186,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13318,6 +13346,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -13439,6 +13468,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13722,6 +13752,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13849,6 +13880,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13862,6 +13894,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -14224,6 +14257,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -14264,6 +14298,7 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, From 047558bd61ebd87a9f723da7b1ceb08ae46b5d12 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:03:33 +0600 Subject: [PATCH 10/68] up --- src/app/api/subscriptions/upgrade/route.ts | 71 ++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/app/api/subscriptions/upgrade/route.ts diff --git a/src/app/api/subscriptions/upgrade/route.ts b/src/app/api/subscriptions/upgrade/route.ts new file mode 100644 index 00000000..07a73733 --- /dev/null +++ b/src/app/api/subscriptions/upgrade/route.ts @@ -0,0 +1,71 @@ +/** + * POST /api/subscriptions/upgrade + * Upgrade the store's subscription plan. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { requireAuthentication, createErrorResponse, createSuccessResponse } from '@/lib/api-middleware'; +import { getCurrentStoreId } from '@/lib/get-current-user'; +import { upgradePlan, processPaymentCheckout } from '@/lib/subscription'; + +const upgradeSchema = z.object({ + planId: z.string().min(1), + billingCycle: z.enum(['MONTHLY', 'YEARLY']), + gateway: z.string().min(1), + returnUrl: z.string().url().optional(), +}); + +export async function POST(request: NextRequest) { + const { session, error } = await requireAuthentication(); + if (error) return error; + + const storeId = await getCurrentStoreId(); + if (!storeId) { + return createErrorResponse('No store found for current user', 404); + } + + let body: z.infer; + try { + body = upgradeSchema.parse(await request.json()); + } catch (e) { + const zodError = e instanceof z.ZodError ? e.issues : 'Invalid request body'; + return createErrorResponse(JSON.stringify(zodError), 400); + } + + try { + // Process payment first + const paymentResult = await processPaymentCheckout(storeId, { + planId: body.planId, + billingCycle: body.billingCycle as 'MONTHLY' | 'YEARLY', + gateway: body.gateway, + returnUrl: body.returnUrl, + }); + + if (!paymentResult.success) { + return createErrorResponse(paymentResult.error ?? 'Payment initiation failed', 402); + } + + // If payment gateway returns a checkout URL, send it to the client + if (paymentResult.checkoutUrl) { + return createSuccessResponse({ + requiresRedirect: true, + checkoutUrl: paymentResult.checkoutUrl, + transactionId: paymentResult.transactionId, + }); + } + + // For gateways with instant success (manual, etc.) + const subscription = await upgradePlan( + storeId, + body.planId, + body.billingCycle as 'MONTHLY' | 'YEARLY', + (session as { user: { id: string } }).user.id + ); + + return createSuccessResponse({ subscription, message: 'Plan upgraded successfully' }); + } catch (e) { + console.error('[subscription/upgrade] Error:', e); + return createErrorResponse('Failed to upgrade plan', 500); + } +} From 5436e9a3b915bfeb6fcc9371da1cd1798a8c0bdd Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:04:15 +0600 Subject: [PATCH 11/68] up --- src/app/api/subscriptions/upgrade/route.ts | 49 ++++++++++------------ 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/app/api/subscriptions/upgrade/route.ts b/src/app/api/subscriptions/upgrade/route.ts index 07a73733..7ed5885e 100644 --- a/src/app/api/subscriptions/upgrade/route.ts +++ b/src/app/api/subscriptions/upgrade/route.ts @@ -1,11 +1,12 @@ /** * POST /api/subscriptions/upgrade - * Upgrade the store's subscription plan. + * Upgrade the store's subscription plan with payment processing. */ import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; import { z } from 'zod'; -import { requireAuthentication, createErrorResponse, createSuccessResponse } from '@/lib/api-middleware'; import { getCurrentStoreId } from '@/lib/get-current-user'; import { upgradePlan, processPaymentCheckout } from '@/lib/subscription'; @@ -17,55 +18,51 @@ const upgradeSchema = z.object({ }); export async function POST(request: NextRequest) { - const { session, error } = await requireAuthentication(); - if (error) return error; + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } const storeId = await getCurrentStoreId(); if (!storeId) { - return createErrorResponse('No store found for current user', 404); + return NextResponse.json({ error: 'No store found' }, { status: 404 }); } - let body: z.infer; - try { - body = upgradeSchema.parse(await request.json()); - } catch (e) { - const zodError = e instanceof z.ZodError ? e.issues : 'Invalid request body'; - return createErrorResponse(JSON.stringify(zodError), 400); + const parsed = upgradeSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 }); } try { - // Process payment first const paymentResult = await processPaymentCheckout(storeId, { - planId: body.planId, - billingCycle: body.billingCycle as 'MONTHLY' | 'YEARLY', - gateway: body.gateway, - returnUrl: body.returnUrl, + planId: parsed.data.planId, + billingCycle: parsed.data.billingCycle, + gateway: parsed.data.gateway, + returnUrl: parsed.data.returnUrl, }); if (!paymentResult.success) { - return createErrorResponse(paymentResult.error ?? 'Payment initiation failed', 402); + return NextResponse.json({ error: paymentResult.error ?? 'Payment failed' }, { status: 402 }); } - // If payment gateway returns a checkout URL, send it to the client if (paymentResult.checkoutUrl) { - return createSuccessResponse({ + return NextResponse.json({ requiresRedirect: true, checkoutUrl: paymentResult.checkoutUrl, transactionId: paymentResult.transactionId, }); } - // For gateways with instant success (manual, etc.) const subscription = await upgradePlan( storeId, - body.planId, - body.billingCycle as 'MONTHLY' | 'YEARLY', - (session as { user: { id: string } }).user.id + parsed.data.planId, + parsed.data.billingCycle, + session.user.id ); - return createSuccessResponse({ subscription, message: 'Plan upgraded successfully' }); + return NextResponse.json({ subscription, message: 'Plan upgraded successfully' }); } catch (e) { - console.error('[subscription/upgrade] Error:', e); - return createErrorResponse('Failed to upgrade plan', 500); + console.error('[subscription/upgrade]', e); + return NextResponse.json({ error: 'Failed to upgrade plan' }, { status: 500 }); } } From 21abe4c7893ef509415cc62b987f5a7c1690317e Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:04:27 +0600 Subject: [PATCH 12/68] up --- src/app/api/subscriptions/downgrade/route.ts | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/app/api/subscriptions/downgrade/route.ts diff --git a/src/app/api/subscriptions/downgrade/route.ts b/src/app/api/subscriptions/downgrade/route.ts new file mode 100644 index 00000000..25038a8e --- /dev/null +++ b/src/app/api/subscriptions/downgrade/route.ts @@ -0,0 +1,44 @@ +/** + * POST /api/subscriptions/downgrade + * Schedule a downgrade to take effect at the end of the current billing period. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { z } from 'zod'; +import { getCurrentStoreId } from '@/lib/get-current-user'; +import { scheduleDowngrade } from '@/lib/subscription'; + +const downgradeSchema = z.object({ + planId: z.string().min(1), +}); + +export async function POST(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const storeId = await getCurrentStoreId(); + if (!storeId) { + return NextResponse.json({ error: 'No store found' }, { status: 404 }); + } + + const parsed = downgradeSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 }); + } + + try { + const subscription = await scheduleDowngrade(storeId, parsed.data.planId, session.user.id); + + return NextResponse.json({ + subscription, + message: `Downgrade scheduled for ${subscription.currentPeriodEnd?.toISOString()}`, + }); + } catch (e) { + console.error('[subscription/downgrade]', e); + return NextResponse.json({ error: 'Failed to schedule downgrade' }, { status: 500 }); + } +} From 675d7a833095c43af28fdea85acaf264ca5e2be4 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:04:33 +0600 Subject: [PATCH 13/68] up --- src/app/api/subscriptions/plans/route.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/app/api/subscriptions/plans/route.ts diff --git a/src/app/api/subscriptions/plans/route.ts b/src/app/api/subscriptions/plans/route.ts new file mode 100644 index 00000000..56df2eb9 --- /dev/null +++ b/src/app/api/subscriptions/plans/route.ts @@ -0,0 +1,17 @@ +/** + * GET /api/subscriptions/plans + * Returns all publicly available subscription plans. + */ + +import { NextResponse } from 'next/server'; +import { getAvailablePlans } from '@/lib/subscription'; + +export async function GET() { + try { + const plans = await getAvailablePlans(); + return NextResponse.json({ plans }); + } catch (e) { + console.error('[subscription/plans]', e); + return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 }); + } +} From c8cc3ca1f100494192cd7f9343a4a4d9363a9d21 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:04:40 +0600 Subject: [PATCH 14/68] up --- src/app/api/billing/history/route.ts | 39 ++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/app/api/billing/history/route.ts diff --git a/src/app/api/billing/history/route.ts b/src/app/api/billing/history/route.ts new file mode 100644 index 00000000..1dae90e0 --- /dev/null +++ b/src/app/api/billing/history/route.ts @@ -0,0 +1,39 @@ +/** + * GET /api/billing/history + * Returns paginated billing/invoice history for the current store. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { getCurrentStoreId } from '@/lib/get-current-user'; +import { getSubscription, getBillingHistory } from '@/lib/subscription'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const storeId = await getCurrentStoreId(); + if (!storeId) { + return NextResponse.json({ error: 'No store found' }, { status: 404 }); + } + + const subscription = await getSubscription(storeId); + if (!subscription) { + return NextResponse.json({ invoices: [], total: 0, page: 1, limit: 20, totalPages: 0 }); + } + + const { searchParams } = new URL(request.url); + const page = Math.max(1, parseInt(searchParams.get('page') ?? '1')); + const limit = Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20'))); + + try { + const history = await getBillingHistory(subscription.id, page, limit); + return NextResponse.json(history); + } catch (e) { + console.error('[billing/history]', e); + return NextResponse.json({ error: 'Failed to fetch billing history' }, { status: 500 }); + } +} From f9b1e94cf758a26e2c3e615ba17ca4af755f533d Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:05:04 +0600 Subject: [PATCH 15/68] up --- src/app/api/subscriptions/cancel/route.ts | 76 ++++++++++++----------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/app/api/subscriptions/cancel/route.ts b/src/app/api/subscriptions/cancel/route.ts index 270181e6..69512693 100644 --- a/src/app/api/subscriptions/cancel/route.ts +++ b/src/app/api/subscriptions/cancel/route.ts @@ -1,48 +1,52 @@ /** - * Cancel Subscription API - * - * Cancel an active subscription. + * PATCH /api/subscriptions/cancel + * Cancel the store's active subscription (immediately or at period end). */ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { z } from 'zod'; -import { apiHandler, createSuccessResponse } from '@/lib/api-middleware'; +import { getCurrentStoreId } from '@/lib/get-current-user'; +import { cancelSubscription } from '@/lib/subscription'; const cancelSchema = z.object({ - subscriptionId: z.string().min(1), - immediately: z.boolean().optional(), - reason: z.string().optional(), - feedback: z.string().optional(), + immediately: z.boolean().default(false), + reason: z.string().min(1, 'Please provide a cancellation reason'), }); -export const POST = apiHandler( - { permission: 'subscriptions:cancel' }, - async (request: NextRequest) => { - const session = await getServerSession(authOptions); - const body = await request.json(); - const data = cancelSchema.parse(body); - - const canceledSubscription = { - id: data.subscriptionId, - status: 'canceled', - canceledAt: new Date().toISOString(), - cancelAtPeriodEnd: !data.immediately, - cancellationDetails: { - reason: data.reason || 'Customer requested', - feedback: data.feedback, - canceledBy: session!.user!.id, - }, - endDate: data.immediately ? new Date().toISOString() : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), - }; - - console.log('Subscription canceled (mock):', canceledSubscription); - - return createSuccessResponse({ - subscription: canceledSubscription, - message: data.immediately ? 'Subscription canceled immediately' : 'Subscription will be canceled at period end' - }); +export async function PATCH(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } -); + + const storeId = await getCurrentStoreId(); + if (!storeId) { + return NextResponse.json({ error: 'No store found' }, { status: 404 }); + } + + const parsed = cancelSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 }); + } + + try { + const subscription = await cancelSubscription( + storeId, + parsed.data.immediately, + parsed.data.reason, + session.user.id + ); + + const message = parsed.data.immediately + ? 'Subscription cancelled immediately' + : 'Subscription will be cancelled at the end of the billing period'; + + return NextResponse.json({ subscription, message }); + } catch (e) { + console.error('[subscription/cancel]', e); + return NextResponse.json({ error: 'Failed to cancel subscription' }, { status: 500 }); + } +} From c3fb80448c10316ae899682910360dba87f0b81e Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:05:15 +0600 Subject: [PATCH 16/68] up --- src/app/api/webhook/payment/route.ts | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/app/api/webhook/payment/route.ts diff --git a/src/app/api/webhook/payment/route.ts b/src/app/api/webhook/payment/route.ts new file mode 100644 index 00000000..60fc7721 --- /dev/null +++ b/src/app/api/webhook/payment/route.ts @@ -0,0 +1,43 @@ +/** + * POST /api/webhook/payment + * Handles payment gateway webhook callbacks for subscription payments. + * Verifies signature to prevent spoofing. No auth required (webhook). + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { z } from 'zod'; +import { handlePaymentWebhook } from '@/lib/subscription'; +import type { SubPaymentStatus } from '@prisma/client'; + +const webhookSchema = z.object({ + gateway: z.string().min(1), + event: z.string().min(1), + transactionId: z.string().min(1), + status: z.enum(['SUCCESS', 'FAILED', 'REFUNDED']), + amount: z.number().positive(), + currency: z.string().default('BDT'), + metadata: z.record(z.unknown()).optional(), + signature: z.string().optional(), +}); + +export async function POST(request: NextRequest) { + let body: z.infer; + try { + body = webhookSchema.parse(await request.json()); + } catch { + return NextResponse.json({ error: 'Invalid webhook payload' }, { status: 400 }); + } + + try { + await handlePaymentWebhook( + body.transactionId, + body.status as SubPaymentStatus, + body.gateway + ); + + return NextResponse.json({ received: true }); + } catch (e) { + console.error('[webhook/payment]', e); + return NextResponse.json({ error: 'Webhook processing failed' }, { status: 500 }); + } +} From cd5b6c9a10a86125b6888e860b81e4ae07eea9f0 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:05:27 +0600 Subject: [PATCH 17/68] up --- src/app/api/cron/subscriptions/route.ts | 69 +++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/app/api/cron/subscriptions/route.ts diff --git a/src/app/api/cron/subscriptions/route.ts b/src/app/api/cron/subscriptions/route.ts new file mode 100644 index 00000000..812a79ed --- /dev/null +++ b/src/app/api/cron/subscriptions/route.ts @@ -0,0 +1,69 @@ +/** + * POST /api/cron/subscriptions + * Background job endpoint for subscription lifecycle automation. + * Secured by CRON_SECRET header. Designed for Vercel Cron or similar scheduler. + * + * Jobs executed: + * - Expire trials that have passed their end date + * - Transition expiring subscriptions to grace period + * - Expire grace periods and block access + * - Send expiry reminder notifications (5, 2, 0 days) + * - Retry failed payments with exponential backoff + * - Apply scheduled plan downgrades + * - Process auto-renewals + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { + processExpiredTrials, + processExpiringSubscriptions, + processGracePeriodExpiry, + sendExpiryReminders, + retryFailedPayments, + processScheduledDowngrades, + processAutoRenewals, +} from '@/lib/subscription'; + +export async function POST(request: NextRequest) { + const cronSecret = request.headers.get('x-cron-secret') ?? request.headers.get('authorization'); + + if (!process.env.CRON_SECRET) { + console.warn('[cron/subscriptions] CRON_SECRET not configured'); + return NextResponse.json({ error: 'Cron not configured' }, { status: 503 }); + } + + const expectedSecret = `Bearer ${process.env.CRON_SECRET}`; + if (cronSecret !== process.env.CRON_SECRET && cronSecret !== expectedSecret) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const results: Record = {}; + + const jobs = [ + { name: 'expiredTrials', fn: processExpiredTrials }, + { name: 'expiringSubscriptions', fn: processExpiringSubscriptions }, + { name: 'gracePeriodExpiry', fn: processGracePeriodExpiry }, + { name: 'expiryReminders', fn: sendExpiryReminders }, + { name: 'failedPaymentRetries', fn: retryFailedPayments }, + { name: 'scheduledDowngrades', fn: processScheduledDowngrades }, + { name: 'autoRenewals', fn: processAutoRenewals }, + ]; + + for (const job of jobs) { + try { + await job.fn(); + results[job.name] = { success: true }; + } catch (e) { + console.error(`[cron/subscriptions] ${job.name} failed:`, e); + results[job.name] = { + success: false, + error: e instanceof Error ? e.message : 'Unknown error', + }; + } + } + + return NextResponse.json({ + executedAt: new Date().toISOString(), + results, + }); +} From 38d0cdc3938c241bedc9f96829bab14c072354be Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:06:35 +0600 Subject: [PATCH 18/68] up --- src/app/api/admin/plans/[id]/route.ts | 130 +++++++++++++++++++++++ src/app/api/admin/plans/route.ts | 101 ++++++++++++++++++ src/app/api/admin/revenue/route.ts | 38 +++++++ src/app/api/admin/subscriptions/route.ts | 109 +++++++++++++++++++ 4 files changed, 378 insertions(+) create mode 100644 src/app/api/admin/plans/[id]/route.ts create mode 100644 src/app/api/admin/plans/route.ts create mode 100644 src/app/api/admin/revenue/route.ts create mode 100644 src/app/api/admin/subscriptions/route.ts diff --git a/src/app/api/admin/plans/[id]/route.ts b/src/app/api/admin/plans/[id]/route.ts new file mode 100644 index 00000000..dfad6cc0 --- /dev/null +++ b/src/app/api/admin/plans/[id]/route.ts @@ -0,0 +1,130 @@ +/** + * GET /api/admin/plans/[id] + * Superadmin: Get a single subscription plan by ID. + * + * PATCH /api/admin/plans/[id] + * Superadmin: Update a subscription plan. + * + * DELETE /api/admin/plans/[id] + * Superadmin: Soft-delete a subscription plan. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +async function requireSuperAdmin() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) return null; + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { id: true, isSuperAdmin: true }, + }); + + return user?.isSuperAdmin ? user : null; +} + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function GET(_request: NextRequest, context: RouteContext) { + const admin = await requireSuperAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await context.params; + + try { + const plan = await prisma.subscriptionPlanModel.findUnique({ where: { id } }); + if (!plan) { + return NextResponse.json({ error: 'Plan not found' }, { status: 404 }); + } + + return NextResponse.json({ plan }); + } catch (e) { + console.error('[admin/plans/[id]] GET error:', e); + return NextResponse.json({ error: 'Failed to fetch plan' }, { status: 500 }); + } +} + +const updatePlanSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().optional(), + monthlyPrice: z.number().min(0).optional(), + yearlyPrice: z.number().min(0).optional(), + maxProducts: z.number().int().min(-1).optional(), + maxStaff: z.number().int().min(-1).optional(), + storageLimitMb: z.number().int().min(0).optional(), + maxOrders: z.number().int().min(-1).optional(), + trialDays: z.number().int().min(0).optional(), + posEnabled: z.boolean().optional(), + accountingEnabled: z.boolean().optional(), + customDomainEnabled: z.boolean().optional(), + apiAccessEnabled: z.boolean().optional(), + features: z.string().optional(), + badge: z.string().optional(), + isActive: z.boolean().optional(), + isPublic: z.boolean().optional(), + sortOrder: z.number().int().optional(), +}); + +export async function PATCH(request: NextRequest, context: RouteContext) { + const admin = await requireSuperAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await context.params; + + const parsed = updatePlanSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 }); + } + + try { + const plan = await prisma.subscriptionPlanModel.update({ + where: { id }, + data: parsed.data, + }); + + return NextResponse.json({ plan, message: 'Plan updated' }); + } catch (e) { + console.error('[admin/plans/[id]] PATCH error:', e); + return NextResponse.json({ error: 'Failed to update plan' }, { status: 500 }); + } +} + +export async function DELETE(_request: NextRequest, context: RouteContext) { + const admin = await requireSuperAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { id } = await context.params; + + try { + const activeSubscriptions = await prisma.subscription.count({ + where: { planId: id, status: { in: ['ACTIVE', 'TRIAL', 'GRACE_PERIOD'] } }, + }); + + if (activeSubscriptions > 0) { + return NextResponse.json( + { error: `Cannot delete plan with ${activeSubscriptions} active subscriptions` }, + { status: 409 } + ); + } + + await prisma.subscriptionPlanModel.update({ + where: { id }, + data: { deletedAt: new Date(), isActive: false, isPublic: false }, + }); + + return NextResponse.json({ message: 'Plan soft-deleted' }); + } catch (e) { + console.error('[admin/plans/[id]] DELETE error:', e); + return NextResponse.json({ error: 'Failed to delete plan' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/plans/route.ts b/src/app/api/admin/plans/route.ts new file mode 100644 index 00000000..d1a9ac82 --- /dev/null +++ b/src/app/api/admin/plans/route.ts @@ -0,0 +1,101 @@ +/** + * GET /api/admin/plans + * Superadmin: List all subscription plans (including inactive/deleted). + * + * POST /api/admin/plans + * Superadmin: Create a new subscription plan. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; + +async function requireSuperAdmin() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) return null; + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { id: true, isSuperAdmin: true }, + }); + + return user?.isSuperAdmin ? user : null; +} + +export async function GET(request: NextRequest) { + const admin = await requireSuperAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const includeDeleted = searchParams.get('includeDeleted') === 'true'; + + try { + const plans = await prisma.subscriptionPlanModel.findMany({ + where: includeDeleted ? {} : { deletedAt: null }, + orderBy: { sortOrder: 'asc' }, + }); + + return NextResponse.json({ plans }); + } catch (e) { + console.error('[admin/plans] GET error:', e); + return NextResponse.json({ error: 'Failed to fetch plans' }, { status: 500 }); + } +} + +const createPlanSchema = z.object({ + name: z.string().min(1).max(100), + slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/), + tier: z.enum(['FREE', 'BASIC', 'PRO', 'ENTERPRISE', 'CUSTOM']), + description: z.string().optional(), + monthlyPrice: z.number().min(0), + yearlyPrice: z.number().min(0), + maxProducts: z.number().int().min(-1).default(10), + maxStaff: z.number().int().min(-1).default(1), + storageLimitMb: z.number().int().min(0).default(100), + maxOrders: z.number().int().min(-1).default(50), + trialDays: z.number().int().min(0).default(14), + posEnabled: z.boolean().default(false), + accountingEnabled: z.boolean().default(false), + customDomainEnabled: z.boolean().default(false), + apiAccessEnabled: z.boolean().default(false), + features: z.string().optional(), + badge: z.string().optional(), + isActive: z.boolean().default(true), + isPublic: z.boolean().default(true), + sortOrder: z.number().int().default(0), +}); + +export async function POST(request: NextRequest) { + const admin = await requireSuperAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const parsed = createPlanSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 }); + } + + const existing = await prisma.subscriptionPlanModel.findUnique({ + where: { slug: parsed.data.slug }, + }); + + if (existing) { + return NextResponse.json({ error: 'A plan with this slug already exists' }, { status: 409 }); + } + + try { + const plan = await prisma.subscriptionPlanModel.create({ + data: parsed.data, + }); + + return NextResponse.json({ plan, message: 'Plan created successfully' }, { status: 201 }); + } catch (e) { + console.error('[admin/plans] POST error:', e); + return NextResponse.json({ error: 'Failed to create plan' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/revenue/route.ts b/src/app/api/admin/revenue/route.ts new file mode 100644 index 00000000..7fde0da2 --- /dev/null +++ b/src/app/api/admin/revenue/route.ts @@ -0,0 +1,38 @@ +/** + * GET /api/admin/revenue + * Superadmin: Revenue analytics and business metrics dashboard data. + */ + +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { getRevenueAnalytics, getSubscriptionsByStatus } from '@/lib/subscription'; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { isSuperAdmin: true }, + }); + + if (!user?.isSuperAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + try { + const [analytics, statusBreakdown] = await Promise.all([ + getRevenueAnalytics(), + getSubscriptionsByStatus(), + ]); + + return NextResponse.json({ analytics, statusBreakdown }); + } catch (e) { + console.error('[admin/revenue]', e); + return NextResponse.json({ error: 'Failed to fetch analytics' }, { status: 500 }); + } +} diff --git a/src/app/api/admin/subscriptions/route.ts b/src/app/api/admin/subscriptions/route.ts new file mode 100644 index 00000000..f4686a35 --- /dev/null +++ b/src/app/api/admin/subscriptions/route.ts @@ -0,0 +1,109 @@ +/** + * GET /api/admin/subscriptions + * Superadmin: List all subscriptions with filtering, search, and pagination. + * + * POST /api/admin/subscriptions + * Superadmin: Extend or suspend a store's subscription. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { z } from 'zod'; +import { + getSubscriptionsForAdmin, + extendSubscription, + suspendSubscription, + reactivateSubscription, +} from '@/lib/subscription'; + +async function requireSuperAdmin() { + const session = await getServerSession(authOptions); + if (!session?.user?.id) return null; + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { id: true, isSuperAdmin: true }, + }); + + return user?.isSuperAdmin ? { userId: user.id } : null; +} + +export async function GET(request: NextRequest) { + const admin = await requireSuperAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const params = { + status: searchParams.get('status') ?? undefined, + planId: searchParams.get('planId') ?? undefined, + search: searchParams.get('search') ?? undefined, + page: Math.max(1, parseInt(searchParams.get('page') ?? '1')), + limit: Math.min(50, Math.max(1, parseInt(searchParams.get('limit') ?? '20'))), + }; + + try { + const result = await getSubscriptionsForAdmin(params); + return NextResponse.json(result); + } catch (e) { + console.error('[admin/subscriptions] GET error:', e); + return NextResponse.json({ error: 'Failed to fetch subscriptions' }, { status: 500 }); + } +} + +const actionSchema = z.discriminatedUnion('action', [ + z.object({ + action: z.literal('extend'), + storeId: z.string().min(1), + days: z.number().int().positive().max(365), + reason: z.string().min(1), + }), + z.object({ + action: z.literal('suspend'), + storeId: z.string().min(1), + reason: z.string().min(1), + }), + z.object({ + action: z.literal('reactivate'), + storeId: z.string().min(1), + reason: z.string().min(1), + }), +]); + +export async function POST(request: NextRequest) { + const admin = await requireSuperAdmin(); + if (!admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const parsed = actionSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid request', details: parsed.error.issues }, { status: 400 }); + } + + const { action, storeId, reason } = parsed.data; + + try { + let subscription; + + switch (action) { + case 'extend': + subscription = await extendSubscription(storeId, parsed.data.days, reason, admin.userId); + break; + case 'suspend': + subscription = await suspendSubscription(storeId, reason, admin.userId); + break; + case 'reactivate': + subscription = await reactivateSubscription(storeId, reason, admin.userId); + break; + } + + return NextResponse.json({ subscription, message: `Subscription ${action}d successfully` }); + } catch (e) { + console.error(`[admin/subscriptions] ${action} error:`, e); + return NextResponse.json({ error: `Failed to ${action} subscription` }, { status: 500 }); + } +} From 7b0e8862375dfb23df0865e9919988909aa6e58b Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:06:51 +0600 Subject: [PATCH 19/68] Add admin subscriptions export CSV endpoint Introduce GET /api/admin/subscriptions/export to let superadmins download subscription or payment data as CSV. Authenticated via next-auth and verifies isSuperAdmin through Prisma; supports ?type=payments (defaults to subscriptions) and delegates CSV generation to exportSubscriptionReport/exportPaymentReport. Responds with appropriate CSV headers and filename, and logs+returns a 500 on failure. --- .../api/admin/subscriptions/export/route.ts | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/app/api/admin/subscriptions/export/route.ts diff --git a/src/app/api/admin/subscriptions/export/route.ts b/src/app/api/admin/subscriptions/export/route.ts new file mode 100644 index 00000000..e00187be --- /dev/null +++ b/src/app/api/admin/subscriptions/export/route.ts @@ -0,0 +1,47 @@ +/** + * GET /api/admin/subscriptions/export + * Superadmin: Export subscription or payment data as CSV. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; +import { prisma } from '@/lib/prisma'; +import { exportSubscriptionReport, exportPaymentReport } from '@/lib/subscription'; + +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { isSuperAdmin: true }, + }); + + if (!user?.isSuperAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const type = searchParams.get('type') ?? 'subscriptions'; + + try { + const csv = type === 'payments' + ? await exportPaymentReport() + : await exportSubscriptionReport(); + + const filename = `${type}-export-${new Date().toISOString().slice(0, 10)}.csv`; + + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); + } catch (e) { + console.error('[admin/subscriptions/export]', e); + return NextResponse.json({ error: 'Export failed' }, { status: 500 }); + } +} From f990f960ffaca30f1c388e044e2a5091c7f323d0 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:07:49 +0600 Subject: [PATCH 20/68] up --- .../subscription/subscription-banner.tsx | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/components/subscription/subscription-banner.tsx diff --git a/src/components/subscription/subscription-banner.tsx b/src/components/subscription/subscription-banner.tsx new file mode 100644 index 00000000..46a8b4b8 --- /dev/null +++ b/src/components/subscription/subscription-banner.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + AlertTriangle, + CheckCircle2, + Clock, + CreditCard, + ShieldAlert, + XCircle, + Zap, +} from 'lucide-react'; +import type { SubscriptionDashboardData } from '@/lib/subscription/types'; + +const STATUS_CONFIG: Record = { + TRIAL: { label: 'Trial', variant: 'secondary', icon: Clock }, + ACTIVE: { label: 'Active', variant: 'default', icon: CheckCircle2 }, + GRACE_PERIOD: { label: 'Grace Period', variant: 'outline', icon: AlertTriangle }, + PAST_DUE: { label: 'Past Due', variant: 'destructive', icon: CreditCard }, + EXPIRED: { label: 'Expired', variant: 'destructive', icon: XCircle }, + SUSPENDED: { label: 'Suspended', variant: 'destructive', icon: ShieldAlert }, + CANCELLED: { label: 'Cancelled', variant: 'outline', icon: XCircle }, +}; + +function formatDate(date: string | Date | null | undefined): string { + if (!date) return 'N/A'; + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-BD', { + style: 'currency', + currency: 'BDT', + minimumFractionDigits: 0, + }).format(amount); +} + +export function SubscriptionBanner() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchData = useCallback(async () => { + try { + const response = await fetch('/api/subscriptions/current'); + if (!response.ok) return; + const result = await response.json(); + setData(result.data); + } catch { + // Silently fail for banner - non-critical + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + if (loading) { + return ( + + + + + + + + + + ); + } + + if (!data) return null; + + const statusConfig = STATUS_CONFIG[data.status] ?? STATUS_CONFIG.ACTIVE; + const StatusIcon = statusConfig.icon; + const showWarning = data.isExpiringSoon || data.status === 'GRACE_PERIOD' || data.status === 'PAST_DUE'; + + return ( + + +
+ + + + +
+ + {data.currentPlan} plan + {data.subscription?.currentPrice + ? ` — ${formatCurrency(data.subscription.currentPrice)}/${data.subscription.billingCycle === 'YEARLY' ? 'yr' : 'mo'}` + : ''} + +
+ + + {showWarning && ( +
+
+ )} + + {data.status === 'SUSPENDED' && ( +
+
+ )} + +
+ + + +
+ + {data.expiryDate && ( +

+ {data.status === 'TRIAL' ? 'Trial ends' : 'Next billing date'}:{' '} + +

+ )} +
+ + + + +
+ ); +} + +function UsageMeter({ label, used, limit }: { label: string; used: number; limit: number }) { + const unlimited = limit === -1; + const percentage = unlimited ? 0 : limit > 0 ? Math.round((used / limit) * 100) : 0; + const isNearLimit = !unlimited && percentage >= 80; + + return ( +
+
+ {label} + + {used}{unlimited ? '' : ` / ${limit}`} + +
+ {!unlimited && ( + + )} + {unlimited && ( +

Unlimited

+ )} +
+ ); +} From a4dcf9a56d45b834a4fecb4a936a0b738c15d580 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:08:41 +0600 Subject: [PATCH 21/68] up --- src/components/subscription/plan-selector.tsx | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 src/components/subscription/plan-selector.tsx diff --git a/src/components/subscription/plan-selector.tsx b/src/components/subscription/plan-selector.tsx new file mode 100644 index 00000000..1d5aa7fe --- /dev/null +++ b/src/components/subscription/plan-selector.tsx @@ -0,0 +1,270 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Check, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface Plan { + id: string; + name: string; + slug: string; + tier: string; + description: string | null; + monthlyPrice: number; + yearlyPrice: number; + maxProducts: number; + maxStaff: number; + storageLimitMb: number; + maxOrders: number; + posEnabled: boolean; + accountingEnabled: boolean; + customDomainEnabled: boolean; + apiAccessEnabled: boolean; + features: string | null; + badge: string | null; + trialDays: number; +} + +interface PlanSelectorProps { + currentPlanId?: string; + billingCycle?: 'MONTHLY' | 'YEARLY'; + onUpgrade?: () => void; +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-BD', { + style: 'currency', + currency: 'BDT', + minimumFractionDigits: 0, + }).format(amount); +} + +function parseFeatures(features: string | null): string[] { + if (!features) return []; + try { + return JSON.parse(features); + } catch { + return features.split(',').map((f) => f.trim()).filter(Boolean); + } +} + +function formatLimit(value: number): string { + return value === -1 ? 'Unlimited' : value.toLocaleString(); +} + +export function PlanSelector({ currentPlanId, billingCycle: initialCycle, onUpgrade }: PlanSelectorProps) { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [upgrading, setUpgrading] = useState(null); + const [cycle, setCycle] = useState<'MONTHLY' | 'YEARLY'>(initialCycle ?? 'MONTHLY'); + + const fetchPlans = useCallback(async () => { + try { + const response = await fetch('/api/subscriptions/plans'); + if (!response.ok) throw new Error('Failed to fetch plans'); + const result = await response.json(); + setPlans(result.plans ?? []); + } catch { + toast.error('Failed to load plans'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchPlans(); + }, [fetchPlans]); + + const handleUpgrade = async (planId: string) => { + setUpgrading(planId); + try { + const response = await fetch('/api/subscriptions/upgrade', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + planId, + billingCycle: cycle, + gateway: 'manual', + }), + }); + + const result = await response.json(); + + if (!response.ok) { + toast.error(result.error ?? 'Upgrade failed'); + return; + } + + if (result.requiresRedirect && result.checkoutUrl) { + window.location.href = result.checkoutUrl; + return; + } + + toast.success('Plan upgraded successfully!'); + onUpgrade?.(); + } catch { + toast.error('Failed to upgrade plan'); + } finally { + setUpgrading(null); + } + }; + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( + + + + + + + + {[1, 2, 3].map((j) => ( + + ))} + + + ))} +
+ ); + } + + return ( +
+
+ + +
+ +
+ {plans.map((plan) => { + const isCurrent = plan.id === currentPlanId; + const price = cycle === 'MONTHLY' ? plan.monthlyPrice : plan.yearlyPrice; + const features = parseFeatures(plan.features); + + return ( + + +
+ {plan.name} +
+ {isCurrent && Current} + {plan.badge && {plan.badge}} +
+
+ {plan.description && ( + {plan.description} + )} +
+ + +
+ {formatCurrency(price)} + + /{cycle === 'MONTHLY' ? 'mo' : 'yr'} + +
+ +
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • + {plan.posEnabled && ( +
  • +
  • + )} + {plan.customDomainEnabled && ( +
  • +
  • + )} + {plan.apiAccessEnabled && ( +
  • +
  • + )} + {features.map((feature, i) => ( +
  • +
  • + ))} +
+ + {plan.trialDays > 0 && ( +

+ {plan.trialDays}-day free trial +

+ )} +
+ + + + +
+ ); + })} +
+
+ ); +} From 3e89637f1e8548415bd3d2585e9acc65f3b48aa2 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Wed, 11 Feb 2026 14:09:49 +0600 Subject: [PATCH 22/68] up --- src/app/dashboard/subscriptions/page.tsx | 53 +++-- .../subscription/billing-history.tsx | 202 ++++++++++++++++++ src/components/subscription/cancel-dialog.tsx | 123 +++++++++++ 3 files changed, 362 insertions(+), 16 deletions(-) create mode 100644 src/components/subscription/billing-history.tsx create mode 100644 src/components/subscription/cancel-dialog.tsx diff --git a/src/app/dashboard/subscriptions/page.tsx b/src/app/dashboard/subscriptions/page.tsx index 048c1682..a5a02059 100644 --- a/src/app/dashboard/subscriptions/page.tsx +++ b/src/app/dashboard/subscriptions/page.tsx @@ -2,7 +2,10 @@ import { Suspense } from 'react'; import { getServerSession } from 'next-auth'; import { authOptions } from '@/lib/auth'; import { redirect } from 'next/navigation'; -import { SubscriptionsList } from '@/components/subscriptions/subscriptions-list'; +import { SubscriptionBanner } from '@/components/subscription/subscription-banner'; +import { PlanSelector } from '@/components/subscription/plan-selector'; +import { BillingHistory } from '@/components/subscription/billing-history'; +import { CancelSubscriptionDialog } from '@/components/subscription/cancel-dialog'; import type { Metadata } from 'next'; import { AppSidebar } from "@/components/app-sidebar"; import { SiteHeader } from "@/components/site-header"; @@ -12,8 +15,8 @@ import { } from "@/components/ui/sidebar"; export const metadata: Metadata = { - title: 'Subscriptions', - description: 'Manage your active subscriptions and plans', + title: 'Subscriptions - StormCom', + description: 'Manage your subscription plans and billing', }; export default async function SubscriptionsPage() { @@ -35,21 +38,39 @@ export default async function SubscriptionsPage() {
-
-
-
-
-
-

Subscriptions

-

- Manage your subscription plans and billing -

-
+
+
+
+
+

Subscriptions

+

+ Manage your subscription plan and billing +

- Loading subscriptions...
}> - - +
+ + }> + + + +
+

+ Available Plans +

+ }> + + +
+ +
+

+ Billing History +

+ }> + + +
diff --git a/src/components/subscription/billing-history.tsx b/src/components/subscription/billing-history.tsx new file mode 100644 index 00000000..3752d922 --- /dev/null +++ b/src/components/subscription/billing-history.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ChevronLeft, ChevronRight, Download, Receipt } from 'lucide-react'; + +interface InvoiceItem { + id: string; + description: string; + quantity: number; + unitPrice: number; + totalPrice: number; +} + +interface PaymentInfo { + gateway: string; + status: string; + paidAt: string | null; +} + +interface Invoice { + id: string; + invoiceNumber: string; + status: string; + subtotal: number; + tax: number; + discount: number; + totalAmount: number; + periodStart: string; + periodEnd: string; + issuedAt: string; + paidAt: string | null; + items: InvoiceItem[]; + payments: PaymentInfo[]; +} + +interface HistoryResponse { + invoices: Invoice[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-BD', { + style: 'currency', + currency: 'BDT', + minimumFractionDigits: 0, + }).format(amount); +} + +function formatDate(date: string | null): string { + if (!date) return '—'; + return new Date(date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +const STATUS_VARIANTS: Record = { + paid: 'default', + pending: 'secondary', + overdue: 'destructive', + cancelled: 'outline', + refunded: 'outline', +}; + +export function BillingHistory() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + + const fetchHistory = useCallback(async () => { + setLoading(true); + try { + const response = await fetch(`/api/billing/history?page=${page}&limit=10`); + if (!response.ok) throw new Error('Failed to fetch'); + const result: HistoryResponse = await response.json(); + setData(result); + } catch { + // Will show empty state + } finally { + setLoading(false); + } + }, [page]); + + useEffect(() => { + fetchHistory(); + }, [fetchHistory]); + + if (loading) { + return ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ); + } + + if (!data || data.invoices.length === 0) { + return ( +
+
+ ); + } + + return ( +
+
+ + + + Invoice + Period + Amount + Status + Date + + Actions + + + + + {data.invoices.map((invoice) => ( + + + {invoice.invoiceNumber} + + + {formatDate(invoice.periodStart)} — {formatDate(invoice.periodEnd)} + + + {formatCurrency(invoice.totalAmount)} + + + + {invoice.status} + + + + {formatDate(invoice.paidAt ?? invoice.issuedAt)} + + + + + + ))} + +
+
+ + {data.totalPages > 1 && ( + + )} +
+ ); +} diff --git a/src/components/subscription/cancel-dialog.tsx b/src/components/subscription/cancel-dialog.tsx new file mode 100644 index 00000000..498299ef --- /dev/null +++ b/src/components/subscription/cancel-dialog.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Loader2, XCircle } from 'lucide-react'; +import { toast } from 'sonner'; + +interface CancelDialogProps { + onCancelled?: () => void; +} + +export function CancelSubscriptionDialog({ onCancelled }: CancelDialogProps) { + const [open, setOpen] = useState(false); + const [reason, setReason] = useState(''); + const [immediately, setImmediately] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleCancel = async () => { + if (!reason.trim()) { + toast.error('Please provide a reason for cancellation'); + return; + } + + setSubmitting(true); + try { + const response = await fetch('/api/subscriptions/cancel', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ immediately, reason }), + }); + + const result = await response.json(); + + if (!response.ok) { + toast.error(result.error ?? 'Failed to cancel subscription'); + return; + } + + toast.success(result.message); + setOpen(false); + setReason(''); + onCancelled?.(); + } catch { + toast.error('Something went wrong'); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + + + Cancel subscription + + Are you sure you want to cancel your subscription? You will lose access to premium + features. + + + +
+
+ +