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:
parent
9d4505d6be
commit
e115a3ab15
@ -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")
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
49
apps/bff/src/subscriptions/sim-usage-store.service.ts
Normal file
49
apps/bff/src/subscriptions/sim-usage-store.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
48
apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx
Normal file
48
apps/portal/src/app/subscriptions/[id]/sim/cancel/page.tsx
Normal 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
|
||||
@ -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>}
|
||||
|
||||
|
||||
@ -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>}
|
||||
|
||||
|
||||
@ -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 ${
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user