diff --git a/src/App.tsx b/src/App.tsx
index 8383a30..b61c103 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -35,6 +35,10 @@ import SystemMembersPage from "@/pages/admin/system-members";
import IssuesPage from "@/pages/admin/issues";
import FaqSupportPage from "@/pages/admin/support/faq";
import NotificationBroadcastPage from "@/pages/admin/notifications/broadcast";
+import EmailTemplatesPage from "@/pages/admin/email-templates";
+import EmailTemplatePreviewPage from "@/pages/admin/email-templates/[key]";
+import SubscriptionsAdminPage from "@/pages/admin/subscriptions";
+import PlanManagementPage from "@/pages/admin/subscriptions/plans/[id]";
function App() {
return (
@@ -110,6 +114,17 @@ function App() {
element={}
/>
} />
+ } />
+ }
+ />
+ } />
+ }
+ />
+
} />
} />
diff --git a/src/config/admin-search-routes.ts b/src/config/admin-search-routes.ts
index 6296288..f3d93d7 100644
--- a/src/config/admin-search-routes.ts
+++ b/src/config/admin-search-routes.ts
@@ -26,6 +26,8 @@ import {
LifeBuoy,
HelpCircle,
Send,
+ Mail,
+ Layers,
} from "lucide-react"
export interface AdminSearchRoute {
@@ -103,9 +105,27 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
title: "Subscription transactions",
description:
"Successful and failed subscription charges for the platform.",
- group: "Commerce",
+ group: "Subscriptions",
icon: ArrowRightLeft,
},
+ {
+ id: "subscription-plans",
+ path: "/admin/subscriptions",
+ title: "Subscription plans",
+ description:
+ "Manage plan pricing, feature flags, limits, and activation.",
+ group: "Subscriptions",
+ icon: Layers,
+ },
+ {
+ id: "email-templates",
+ path: "/admin/email-templates",
+ title: "Email templates",
+ description:
+ "Preview transactional email templates with sample data.",
+ group: "Communications",
+ icon: Mail,
+ },
{
id: "users",
path: "/admin/users",
@@ -299,6 +319,7 @@ export const ADMIN_SEARCH_ROUTES: AdminSearchRoute[] = [
const GROUP_ORDER = [
"Overview",
"Commerce",
+ "Subscriptions",
"People & activity",
"Support",
"Operations",
diff --git a/src/layouts/app-shell.tsx b/src/layouts/app-shell.tsx
index 8351bb2..4dbe40c 100644
--- a/src/layouts/app-shell.tsx
+++ b/src/layouts/app-shell.tsx
@@ -24,6 +24,8 @@ import {
ChevronDown,
ChevronRight,
Folder,
+ Mail,
+ Layers,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { AdminQuickSearch } from "@/components/admin-quick-search";
@@ -56,6 +58,31 @@ type NavItem = {
visible?: (role: string | undefined) => boolean;
};
+function navItemIsActive(
+ item: NavItem,
+ isActive: (path?: string) => boolean,
+): boolean {
+ if (item.path && isActive(item.path)) return true;
+ return item.children?.some((child) => navItemIsActive(child, isActive)) ?? false;
+}
+
+function filterNavItems(
+ items: NavItem[],
+ role: string | undefined,
+): NavItem[] {
+ return items
+ .filter((item) => (item.visible ? item.visible(role) : true))
+ .map((item) =>
+ item.children
+ ? {
+ ...item,
+ children: filterNavItems(item.children, role),
+ }
+ : item,
+ )
+ .filter((item) => !item.children || item.children.length > 0);
+}
+
const adminNavigationItems: NavItem[] = [
{ icon: LayoutDashboard, label: "Dashboard", path: "/admin/dashboard" },
{
@@ -91,8 +118,19 @@ const adminNavigationItems: NavItem[] = [
},
{
icon: ArrowRightLeft,
- label: "Subscription transactions",
- path: "/admin/transactions/subscriptions",
+ label: "Subscriptions",
+ children: [
+ {
+ label: "Transactions",
+ path: "/admin/transactions/subscriptions",
+ icon: ArrowRightLeft,
+ },
+ {
+ label: "Plans",
+ path: "/admin/subscriptions",
+ icon: Layers,
+ },
+ ],
},
{
icon: Users,
@@ -153,9 +191,20 @@ const adminNavigationItems: NavItem[] = [
},
{
icon: Send,
- label: "Send notification",
- path: "/admin/notifications/broadcast",
- visible: (role) => getPermissions(role).canSendNotifications,
+ label: "Communications",
+ children: [
+ {
+ label: "Send notification",
+ path: "/admin/notifications/broadcast",
+ icon: Send,
+ visible: (role) => getPermissions(role).canSendNotifications,
+ },
+ {
+ label: "Email templates",
+ path: "/admin/email-templates",
+ icon: Mail,
+ },
+ ],
},
];
@@ -163,15 +212,18 @@ const SidebarNavItem = ({
item,
depth = 0,
isActive,
+ role,
}: {
item: NavItem;
depth?: number;
isActive: (path?: string) => boolean;
+ role: string | undefined;
}) => {
- const hasChildren = item.children && item.children.length > 0;
- const isCurrentlyActive =
- isActive(item.path) ||
- (hasChildren && item.children?.some((child) => isActive(child.path)));
+ const visibleChildren = item.children
+ ? filterNavItems(item.children, role)
+ : undefined;
+ const hasChildren = visibleChildren && visibleChildren.length > 0;
+ const isCurrentlyActive = navItemIsActive(item, isActive);
const [isOpen, setIsOpen] = useState(isCurrentlyActive);
@@ -209,12 +261,13 @@ const SidebarNavItem = ({
{isOpen && (
- {item.children?.map((child) => (
+ {visibleChildren?.map((child) => (
))}
@@ -309,13 +362,12 @@ export function AppShell() {
{/* Navigation */}
diff --git a/src/pages/admin/email-templates/[key].tsx b/src/pages/admin/email-templates/[key].tsx
new file mode 100644
index 0000000..f340228
--- /dev/null
+++ b/src/pages/admin/email-templates/[key].tsx
@@ -0,0 +1,94 @@
+import { useParams, useNavigate } from "react-router-dom"
+import { useQuery } from "@tanstack/react-query"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { ArrowLeft, ExternalLink, Loader2 } from "lucide-react"
+import { emailService } from "@/services"
+
+export default function EmailTemplatePreviewPage() {
+ const { key } = useParams()
+ const navigate = useNavigate()
+
+ const { data: templatesData } = useQuery({
+ queryKey: ['admin', 'email-templates'],
+ queryFn: () => emailService.listPreviewTemplates(),
+ })
+
+ const { data: preview, isLoading, error } = useQuery({
+ queryKey: ['admin', 'email-templates', key, 'preview'],
+ queryFn: () => emailService.getPreviewJson(key!),
+ enabled: !!key,
+ })
+
+ const templateMeta = templatesData?.templates.find((t) => t.key === key)
+
+ if (isLoading) {
+ return (
+
+
+ Rendering preview...
+
+ )
+ }
+
+ if (error || !preview) {
+ return (
+
+
+
+ Failed to load email preview.
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
{templateMeta?.name ?? preview.templateKey}
+
{templateMeta?.description}
+
+
+
+
+
+
+
+
+ Subject
+ {preview.subject}
+ {preview.templateKey}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/email-templates/index.tsx b/src/pages/admin/email-templates/index.tsx
new file mode 100644
index 0000000..b10b599
--- /dev/null
+++ b/src/pages/admin/email-templates/index.tsx
@@ -0,0 +1,82 @@
+import { useNavigate } from "react-router-dom"
+import { useQuery } from "@tanstack/react-query"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Mail, Eye, Loader2 } from "lucide-react"
+import { emailService } from "@/services"
+
+export default function EmailTemplatesPage() {
+ const navigate = useNavigate()
+
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['admin', 'email-templates'],
+ queryFn: () => emailService.listPreviewTemplates(),
+ })
+
+ if (isLoading) {
+ return (
+
+
+ Loading email templates...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ Failed to load email templates.
+
+ )
+ }
+
+ const templates = data?.templates ?? []
+
+ return (
+
+
+
Email Templates
+
+ Preview React Email templates with sample data and company branding.
+
+
+
+
+ {templates.map((template) => (
+
navigate(`/admin/email-templates/${template.key}`)}
+ >
+
+
+
+
+
+
+
+ {template.name}
+
+ {template.key}
+
+
+
+
+ {template.description}
+
+
+
+ Subject: {template.defaultSubject}
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/pages/admin/subscriptions/index.tsx b/src/pages/admin/subscriptions/index.tsx
new file mode 100644
index 0000000..070d11c
--- /dev/null
+++ b/src/pages/admin/subscriptions/index.tsx
@@ -0,0 +1,105 @@
+import { useNavigate } from "react-router-dom";
+import { useQuery } from "@tanstack/react-query";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Edit, Loader2 } from "lucide-react";
+import { subscriptionService } from "@/services";
+
+export default function SubscriptionsAdminPage() {
+ const navigate = useNavigate();
+
+ const { data: plans, isLoading: plansLoading } = useQuery({
+ queryKey: ["admin", "subscription-plans"],
+ queryFn: () => subscriptionService.getAdminPlans(),
+ });
+
+ return (
+
+
+
Subscription Plans
+
+ Manage plan pricing, feature flags, limits, and activation status.
+ Payment history is under{" "}
+
+ .
+
+
+
+
+
+ All Plans
+
+
+ {plansLoading ? (
+
+
+ Loading plans...
+
+ ) : (
+
+
+
+ Plan
+ Monthly (ETB)
+ Yearly (ETB)
+ Status
+ Actions
+
+
+
+ {plans?.map((plan) => (
+
+
+
+
{plan.displayName}
+
{plan.name}
+
+
+
+ {plan.isFree ? "Free" : plan.monthlyPrice.toLocaleString()}
+
+
+ {plan.isFree ? "Free" : plan.yearlyPrice.toLocaleString()}
+
+
+
+ {plan.isActive ? "Active" : "Inactive"}
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/admin/subscriptions/plans/[id].tsx b/src/pages/admin/subscriptions/plans/[id].tsx
new file mode 100644
index 0000000..17876c9
--- /dev/null
+++ b/src/pages/admin/subscriptions/plans/[id].tsx
@@ -0,0 +1,250 @@
+import { useEffect, useState } from "react"
+import { useParams, useNavigate } from "react-router-dom"
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Switch } from "@/components/ui/switch"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { ArrowLeft, Loader2 } from "lucide-react"
+import { subscriptionService, type PlanFeatures } from "@/services"
+import { toast } from "sonner"
+import type { ApiError } from "@/types/error.types"
+
+function formatFeatureLabel(key: string) {
+ return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
+}
+
+export default function PlanManagementPage() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const queryClient = useQueryClient()
+
+ const [displayName, setDisplayName] = useState("")
+ const [description, setDescription] = useState("")
+ const [monthlyPrice, setMonthlyPrice] = useState("")
+ const [yearlyPrice, setYearlyPrice] = useState("")
+ const [isActive, setIsActive] = useState(true)
+ const [features, setFeatures] = useState({ features: {}, limits: {} })
+
+ const { data: plan, isLoading } = useQuery({
+ queryKey: ['admin', 'subscription-plans', id],
+ queryFn: () => subscriptionService.getAdminPlan(id!),
+ enabled: !!id,
+ })
+
+ useEffect(() => {
+ if (plan) {
+ setDisplayName(plan.displayName)
+ setDescription(plan.description ?? "")
+ setMonthlyPrice(String(plan.monthlyPrice))
+ setYearlyPrice(String(plan.yearlyPrice))
+ setIsActive(plan.isActive)
+ setFeatures(plan.features)
+ }
+ }, [plan])
+
+ const updatePlanMutation = useMutation({
+ mutationFn: () =>
+ subscriptionService.updatePlan(id!, {
+ displayName,
+ description,
+ monthlyPrice: Number(monthlyPrice),
+ yearlyPrice: Number(yearlyPrice),
+ isActive,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'subscription-plans'] })
+ toast.success("Plan settings updated")
+ },
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to update plan")
+ },
+ })
+
+ const updateFeaturesMutation = useMutation({
+ mutationFn: () => subscriptionService.updatePlanFeatures(id!, features),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin', 'subscription-plans'] })
+ toast.success("Plan features updated")
+ },
+ onError: (error) => {
+ const apiError = error as ApiError
+ toast.error(apiError.response?.data?.message || "Failed to update features")
+ },
+ })
+
+ const toggleFeature = (key: string) => {
+ setFeatures((prev) => ({
+ ...prev,
+ features: { ...prev.features, [key]: !prev.features[key] },
+ }))
+ }
+
+ const updateLimit = (key: string, value: string) => {
+ const parsed = value.trim() === "" ? null : Number(value)
+ setFeatures((prev) => ({
+ ...prev,
+ limits: { ...prev.limits, [key]: parsed },
+ }))
+ }
+
+ if (isLoading) {
+ return (
+
+
+ Loading plan...
+
+ )
+ }
+
+ if (!plan) {
+ return Plan not found.
+ }
+
+ return (
+
+
+
+
+
{plan.displayName}
+
{plan.name} plan
+
+
+
+
+
+ Pricing & Status
+ Features & Limits
+
+
+
+
+
+ Pricing Settings
+
+
+
+
+ setDisplayName(e.target.value)}
+ />
+
+
+
+ setDescription(e.target.value)}
+ />
+
+ {!plan.isFree && (
+ <>
+
+
+ setMonthlyPrice(e.target.value)}
+ />
+
+
+
+ setYearlyPrice(e.target.value)}
+ />
+
+ >
+ )}
+
+
+
+
+ Inactive plans are hidden from new subscriptions.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Feature Flags
+
+
+ {Object.entries(features.features).map(([key, enabled]) => (
+
+
+ toggleFeature(key)}
+ />
+
+ ))}
+
+
+
+
+
+ Usage Limits
+
+
+ {Object.entries(features.limits).map(([key, limit]) => (
+
+
+ updateLimit(key, e.target.value)}
+ />
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/admin/transactions/subscription-transactions.tsx b/src/pages/admin/transactions/subscription-transactions.tsx
index 4eff612..432b10f 100644
--- a/src/pages/admin/transactions/subscription-transactions.tsx
+++ b/src/pages/admin/transactions/subscription-transactions.tsx
@@ -21,11 +21,11 @@ import type { SubscriptionPaymentStatus } from "@/services/subscription-transact
import { cn } from "@/lib/utils";
export default function SubscriptionTransactionsPage() {
- const [tab, setTab] = useState<"succeeded" | "failed">("succeeded");
+ const [tab, setTab] = useState<"completed" | "failed">("completed");
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const status: SubscriptionPaymentStatus =
- tab === "succeeded" ? "SUCCEEDED" : "FAILED";
+ tab === "completed" ? "COMPLETED" : "FAILED";
const { data, isLoading, error } = useQuery({
queryKey: ["admin", "subscription-transactions", status, page, search],
@@ -76,18 +76,18 @@ export default function SubscriptionTransactionsPage() {
{
- setTab(v as "succeeded" | "failed");
+ setTab(v as "completed" | "failed");
setPage(1);
}}
className="w-full sm:w-auto"
>
- Succeeded
+ Completed
Failed to synchronize with banking ledger. Verify{" "}
- GET /admin/subscription-transactions
+ GET /subscription/admin/transactions
{" "}
is reachable.
@@ -238,7 +238,7 @@ export default function SubscriptionTransactionsPage() {
("MONTHLY");
const [editForm, setEditForm] = useState({
firstName: "",
lastName: "",
@@ -52,6 +62,36 @@ export default function UserDetailsPage() {
enabled: !!id,
});
+ const { data: subscription, isLoading: subscriptionLoading } = useQuery({
+ queryKey: ["admin", "users", id, "subscription"],
+ queryFn: () => subscriptionService.getUserSubscription(id!),
+ enabled: !!id,
+ });
+
+ const { data: plans } = useQuery({
+ queryKey: ["admin", "subscription-plans"],
+ queryFn: () => subscriptionService.getAdminPlans(),
+ });
+
+ const assignPlanMutation = useMutation({
+ mutationFn: () =>
+ subscriptionService.assignPlan(id!, {
+ planId: assignPlanId,
+ billingInterval: assignBillingInterval,
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["admin", "users", id, "subscription"],
+ });
+ toast.success("Plan assigned successfully");
+ setIsAssignDialogOpen(false);
+ },
+ onError: (error) => {
+ const apiError = error as ApiError;
+ toast.error(apiError.response?.data?.message || "Failed to assign plan");
+ },
+ });
+
const handleEditClick = () => {
if (user) {
setEditForm({
@@ -104,6 +144,7 @@ export default function UserDetailsPage() {
Information
+ Subscription
Statistics
+
+
+
+
+ Subscription Details
+ {canEditUsers && (
+
+ )}
+
+
+
+ {subscriptionLoading ? (
+
+
+ Loading subscription...
+
+ ) : subscription ? (
+
+
+
+
Plan
+
{subscription.plan.displayName}
+
+
+
Status
+
{subscription.subscription.status}
+
+
+
Billing
+
{subscription.subscription.billingInterval}
+
+
+
Period End
+
+ {format(new Date(subscription.subscription.currentPeriodEnd), 'PP')}
+
+
+
+
+
+
Usage This Period
+
+ {Object.entries(subscription.usage).map(([key, { used, limit }]) => (
+
+
+ {key.replace(/_/g, ' ')}
+
+
+ {used} / {limit === null ? '∞' : limit}
+
+
+ ))}
+
+
+
+ ) : (
+ No subscription data available.
+ )}
+
+
+
+
@@ -324,6 +437,63 @@ export default function UserDetailsPage() {
+
+
);
}
diff --git a/src/services/email.service.ts b/src/services/email.service.ts
new file mode 100644
index 0000000..1137552
--- /dev/null
+++ b/src/services/email.service.ts
@@ -0,0 +1,26 @@
+import apiClient from './api/client'
+import type {
+ EmailPreviewResult,
+ EmailTemplateListResponse,
+} from '@/types/email.types'
+
+class EmailService {
+ async listPreviewTemplates(): Promise {
+ const response = await apiClient.get('/emails/preview/templates')
+ return response.data
+ }
+
+ async getPreviewJson(templateKey: string): Promise {
+ const response = await apiClient.get(
+ `/emails/preview/${templateKey}/json`,
+ )
+ return response.data
+ }
+
+ getPreviewHtmlUrl(templateKey: string): string {
+ const baseUrl = import.meta.env.VITE_BACKEND_API_URL || 'http://localhost:3001/api/v1'
+ return `${baseUrl}/emails/preview/${templateKey}`
+ }
+}
+
+export const emailService = new EmailService()
diff --git a/src/services/index.ts b/src/services/index.ts
index 1eb6c7e..baf3d62 100644
--- a/src/services/index.ts
+++ b/src/services/index.ts
@@ -15,6 +15,8 @@ export { subscriptionTransactionService } from "./subscription-transaction.servi
export { systemMemberService } from "./system-member.service";
export { issueService } from "./issue.service";
export { faqService } from "./faq.service";
+export { subscriptionService } from "./subscription.service";
+export { emailService } from "./email.service";
// Export types
export type { LoginRequest, LoginResponse } from "./auth.service";
@@ -73,3 +75,18 @@ export type {
export type { SystemMember, CreateSystemMemberPayload } from "./system-member.service";
export type { SupportIssue, IssueStatus } from "./issue.service";
export type { FaqEntry, FaqAudience } from "./faq.service";
+export type {
+ SubscriptionPlan,
+ PlanFeatures,
+ UpdatePlanData,
+ AssignPlanData,
+ UserSubscriptionSummary,
+ SubscriptionTransaction as AdminSubscriptionTransaction,
+ GetSubscriptionTransactionsParams,
+ BillingInterval,
+ ChapaTransactionStatus,
+} from "@/types/subscription.types";
+export type {
+ EmailTemplateMeta,
+ EmailPreviewResult,
+} from "@/types/email.types";
diff --git a/src/services/subscription-transaction.service.ts b/src/services/subscription-transaction.service.ts
index 5af3cc2..ef9a382 100644
--- a/src/services/subscription-transaction.service.ts
+++ b/src/services/subscription-transaction.service.ts
@@ -1,6 +1,6 @@
import apiClient from "./api/client"
-export type SubscriptionPaymentStatus = "SUCCEEDED" | "FAILED" | "PENDING"
+export type SubscriptionPaymentStatus = "COMPLETED" | "FAILED" | "PENDING"
export interface SubscriptionTransaction {
id: string
@@ -36,7 +36,7 @@ class SubscriptionTransactionService {
filters: SubscriptionTransactionFilters = {},
): Promise {
const response = await apiClient.get(
- "/admin/subscription-transactions",
+ "/subscription/admin/transactions",
{ params: filters },
)
return response.data
diff --git a/src/services/subscription.service.ts b/src/services/subscription.service.ts
new file mode 100644
index 0000000..85ffaf0
--- /dev/null
+++ b/src/services/subscription.service.ts
@@ -0,0 +1,67 @@
+import apiClient from './api/client'
+import type {
+ AssignPlanData,
+ GetSubscriptionTransactionsParams,
+ PlanFeatures,
+ SubscriptionPlan,
+ SubscriptionTransactionsResponse,
+ UpdatePlanData,
+ UserSubscriptionSummary,
+} from '@/types/subscription.types'
+
+class SubscriptionService {
+ async getAdminPlans(): Promise {
+ const response = await apiClient.get('/subscription/admin/plans')
+ return response.data
+ }
+
+ async getAdminPlan(id: string): Promise {
+ const response = await apiClient.get(`/subscription/admin/plans/${id}`)
+ return response.data
+ }
+
+ async updatePlan(id: string, data: UpdatePlanData): Promise {
+ const response = await apiClient.put(`/subscription/admin/plans/${id}`, data)
+ return response.data
+ }
+
+ async updatePlanFeatures(id: string, features: PlanFeatures): Promise {
+ const response = await apiClient.put(
+ `/subscription/admin/plans/${id}/features`,
+ features,
+ )
+ return response.data
+ }
+
+ async getUserSubscription(userId: string): Promise {
+ const response = await apiClient.get(
+ `/subscription/admin/users/${userId}`,
+ )
+ return response.data
+ }
+
+ async assignPlan(userId: string, data: AssignPlanData) {
+ const response = await apiClient.post(`/subscription/admin/users/${userId}/assign`, data)
+ return response.data
+ }
+
+ async getSubscriptionTransactions(
+ params?: GetSubscriptionTransactionsParams,
+ ): Promise {
+ const queryParams = params
+ ? {
+ ...params,
+ page: params.page ? Number(params.page) : undefined,
+ limit: params.limit ? Number(params.limit) : undefined,
+ }
+ : undefined
+
+ const response = await apiClient.get(
+ '/subscription/admin/transactions',
+ { params: queryParams },
+ )
+ return response.data
+ }
+}
+
+export const subscriptionService = new SubscriptionService()
diff --git a/src/types/email.types.ts b/src/types/email.types.ts
new file mode 100644
index 0000000..046589d
--- /dev/null
+++ b/src/types/email.types.ts
@@ -0,0 +1,18 @@
+export interface EmailTemplateMeta {
+ key: string
+ name: string
+ description: string
+ defaultSubject: string
+ previewUrl: string
+ previewJsonUrl: string
+}
+
+export interface EmailTemplateListResponse {
+ templates: EmailTemplateMeta[]
+}
+
+export interface EmailPreviewResult {
+ templateKey: string
+ subject: string
+ html: string
+}
diff --git a/src/types/subscription.types.ts b/src/types/subscription.types.ts
new file mode 100644
index 0000000..1be5038
--- /dev/null
+++ b/src/types/subscription.types.ts
@@ -0,0 +1,117 @@
+export type BillingInterval = 'MONTHLY' | 'YEARLY'
+
+export type SubscriptionStatus =
+ | 'ACTIVE'
+ | 'PAST_DUE'
+ | 'CANCELLED'
+ | 'EXPIRED'
+ | 'TRIALING'
+
+export type ChapaTransactionStatus =
+ | 'PENDING'
+ | 'SUCCESS'
+ | 'FAILED'
+ | 'CANCELLED'
+
+export interface PlanFeatures {
+ features: Record
+ limits: Record
+}
+
+export interface SubscriptionPlan {
+ id: string
+ name: string
+ displayName: string
+ description: string
+ isFree: boolean
+ isActive: boolean
+ sortOrder: number
+ monthlyPrice: number
+ yearlyPrice: number
+ features: PlanFeatures
+ createdAt?: string
+ updatedAt?: string
+}
+
+export interface UpdatePlanData {
+ displayName?: string
+ description?: string
+ monthlyPrice?: number
+ yearlyPrice?: number
+ isActive?: boolean
+}
+
+export interface AssignPlanData {
+ planId: string
+ billingInterval?: BillingInterval
+ externalRef?: string
+}
+
+export interface UserSubscriptionSummary {
+ plan: {
+ id: string
+ name: string
+ displayName: string
+ isFree: boolean
+ }
+ subscription: {
+ id: string
+ status: SubscriptionStatus
+ billingInterval: BillingInterval
+ currentPeriodStart: string
+ currentPeriodEnd: string
+ nextBillingAt: string | null
+ trialEndsAt: string | null
+ }
+ features: Record
+ usage: Record
+}
+
+export interface SubscriptionTransactionUser {
+ id: string
+ email: string
+ firstName: string | null
+ lastName: string | null
+}
+
+export interface SubscriptionTransactionPlan {
+ id: string
+ name: string
+ displayName: string
+}
+
+export interface SubscriptionTransaction {
+ id: string
+ txRef: string
+ checkoutUrl: string | null
+ totalAmount: number
+ currency: string
+ status: ChapaTransactionStatus
+ purpose: string
+ billingInterval: BillingInterval | null
+ userId: string
+ planId: string | null
+ createdAt: string
+ user: SubscriptionTransactionUser
+ plan: SubscriptionTransactionPlan | null
+}
+
+export interface SubscriptionTransactionsResponse {
+ data: SubscriptionTransaction[]
+ meta: {
+ total: number
+ page: number
+ limit: number
+ totalPages: number
+ hasNextPage: boolean
+ hasPreviousPage: boolean
+ }
+}
+
+export interface GetSubscriptionTransactionsParams {
+ page?: number
+ limit?: number
+ userId?: string
+ planId?: string
+ status?: ChapaTransactionStatus
+}