Add daily SIM usage tracking and persistence functionality

- Introduced SimUsageDaily model to store daily usage snapshots for SIM accounts.
- Updated SimManagementService to persist daily usage data and clean up previous months' records.
- Enhanced error handling for usage persistence failures.
- Updated SubscriptionsModule to include SimUsageStoreService for managing usage data.
- Improved user interface text for clarity in SIM management pages.
This commit is contained in:
tema 2025-09-06 10:01:52 +09:00
parent 9d4505d6be
commit e115a3ab15
8 changed files with 139 additions and 7 deletions

View File

@ -170,3 +170,17 @@ enum AuditAction {
MFA_DISABLED
API_ACCESS
}
// Per-SIM daily usage snapshot used to build full-month charts
model SimUsageDaily {
id Int @id @default(autoincrement())
account String
date DateTime @db.Date
usageMb Float
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([account, date])
@@index([account, date])
@@map("sim_usage_daily")
}

View File

@ -4,6 +4,7 @@ import { FreebititService } from '../vendors/freebit/freebit.service';
import { MappingsService } from '../mappings/mappings.service';
import { SubscriptionsService } from './subscriptions.service';
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
import { SimUsageStoreService } from './sim-usage-store.service';
import { getErrorMessage } from '../common/utils/error.util';
export interface SimTopUpRequest {
@ -42,6 +43,7 @@ export class SimManagementService {
private readonly mappingsService: MappingsService,
private readonly subscriptionsService: SubscriptionsService,
@Inject(Logger) private readonly logger: Logger,
private readonly usageStore: SimUsageStoreService,
) {}
/**
@ -184,6 +186,22 @@ export class SimManagementService {
const { account } = await this.validateSimSubscription(userId, subscriptionId);
const simUsage = await this.freebititService.getSimUsage(account);
// Persist today's usage for monthly charts and cleanup previous months
try {
await this.usageStore.upsertToday(account, simUsage.todayUsageMb);
await this.usageStore.cleanupPreviousMonths();
const stored = await this.usageStore.getLastNDays(account, 30);
if (stored.length > 0) {
simUsage.recentDaysUsage = stored.map(d => ({
date: d.date,
usageKb: Math.round(d.usageMb * 1024),
usageMb: d.usageMb,
}));
}
} catch (e) {
this.logger.warn('SIM usage persistence failed (non-fatal)', { account, error: getErrorMessage(e) });
}
this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, {
userId,

View File

@ -0,0 +1,49 @@
import { Injectable, Inject } from "@nestjs/common";
import { PrismaService } from "../common/prisma/prisma.service";
import { Logger } from "nestjs-pino";
@Injectable()
export class SimUsageStoreService {
constructor(
private readonly prisma: PrismaService,
@Inject(Logger) private readonly logger: Logger,
) {}
private normalizeDate(date?: Date): Date {
const d = date ? new Date(date) : new Date();
// strip time to YYYY-MM-DD
const iso = d.toISOString().split('T')[0];
return new Date(iso + 'T00:00:00.000Z');
}
async upsertToday(account: string, usageMb: number, date?: Date): Promise<void> {
const day = this.normalizeDate(date);
try {
await (this.prisma as any).simUsageDaily.upsert({
where: { account_date: { account, date: day } as any },
update: { usageMb },
create: { account, date: day, usageMb },
});
} catch (e: any) {
this.logger.error("Failed to upsert daily usage", { account, error: e?.message });
}
}
async getLastNDays(account: string, days = 30): Promise<Array<{ date: string; usageMb: number }>> {
const end = this.normalizeDate();
const start = new Date(end);
start.setUTCDate(end.getUTCDate() - (days - 1));
const rows = await (this.prisma as any).simUsageDaily.findMany({
where: { account, date: { gte: start, lte: end } },
orderBy: { date: 'desc' },
}) as Array<{ date: Date; usageMb: number }>;
return rows.map((r) => ({ date: r.date.toISOString().split('T')[0], usageMb: r.usageMb }));
}
async cleanupPreviousMonths(): Promise<number> {
const now = new Date();
const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const result = await (this.prisma as any).simUsageDaily.deleteMany({ where: { date: { lt: firstOfMonth } } });
return result.count;
}
}

View File

@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { SubscriptionsController } from "./subscriptions.controller";
import { SubscriptionsService } from "./subscriptions.service";
import { SimManagementService } from "./sim-management.service";
import { SimUsageStoreService } from "./sim-usage-store.service";
import { WhmcsModule } from "../vendors/whmcs/whmcs.module";
import { MappingsModule } from "../mappings/mappings.module";
import { FreebititModule } from "../vendors/freebit/freebit.module";
@ -9,6 +10,6 @@ import { FreebititModule } from "../vendors/freebit/freebit.module";
@Module({
imports: [WhmcsModule, MappingsModule, FreebititModule],
controllers: [SubscriptionsController],
providers: [SubscriptionsService, SimManagementService],
providers: [SubscriptionsService, SimManagementService, SimUsageStoreService],
})
export class SubscriptionsModule {}

View File

@ -0,0 +1,48 @@
\"use client\";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { authenticatedApi } from "@/lib/api";
export default function SimCancelPage() {
const params = useParams();
const subscriptionId = parseInt(params.id as string);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
setLoading(true);
setMessage(null);
setError(null);
try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
setMessage("SIM service cancelled successfully");
} catch (e: any) {
setError(e instanceof Error ? e.message : "Failed to cancel SIM service");
} finally {
setLoading(false);
}
};
return (
<DashboardLayout>
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-2">Cancel SIM</h1>
<p className="text-sm text-gray-600 mb-6">Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately.</p>
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>}
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>}
<div className="bg-red-50 border border-red-200 rounded p-4 mb-4 text-sm text-red-800">
This is a destructive action. Your service will be terminated immediately.
</div>
<div className="flex gap-3">
<button onClick={submit} disabled={loading} className="px-4 py-2 rounded-md bg-red-600 text-white text-sm disabled:opacity-50">{loading ? Processing : Cancel

View File

@ -59,8 +59,7 @@ export default function SimChangePlanPage() {
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Change Plan</h1>
<p className="text-sm text-gray-600 mb-6">Switch to a different data plan. Important: request before the 25th; takes effect on the 1st.</p>
<p className="text-sm text-gray-600 mb-6">Change Plan: Switch to a different data plan. Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</p>
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>}
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>}

View File

@ -47,8 +47,7 @@ export default function SimTopUpPage() {
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1>
<p className="text-sm text-gray-600 mb-6">Add data quota to your SIM service</p>
<p className="text-sm text-gray-600 mb-6">Top Up Data: Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed.</p>
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>}
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>}

View File

@ -206,8 +206,12 @@ export function SimActions({
<button
onClick={() => {
setActiveInfo('cancel');
// keep inline confirm for cancel to avoid accidental navigation
setShowCancelConfirm(true);
try {
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
} catch {
// Fallback to inline confirm if router not available
setShowCancelConfirm(true);
}
}}
disabled={!canCancel || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${