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
|
MFA_DISABLED
|
||||||
API_ACCESS
|
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 { MappingsService } from '../mappings/mappings.service';
|
||||||
import { SubscriptionsService } from './subscriptions.service';
|
import { SubscriptionsService } from './subscriptions.service';
|
||||||
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
|
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
|
||||||
|
import { SimUsageStoreService } from './sim-usage-store.service';
|
||||||
import { getErrorMessage } from '../common/utils/error.util';
|
import { getErrorMessage } from '../common/utils/error.util';
|
||||||
|
|
||||||
export interface SimTopUpRequest {
|
export interface SimTopUpRequest {
|
||||||
@ -42,6 +43,7 @@ export class SimManagementService {
|
|||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@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 { account } = await this.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
const simUsage = await this.freebititService.getSimUsage(account);
|
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}`, {
|
this.logger.log(`Retrieved SIM usage for subscription ${subscriptionId}`, {
|
||||||
userId,
|
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 { SubscriptionsController } from "./subscriptions.controller";
|
||||||
import { SubscriptionsService } from "./subscriptions.service";
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
import { SimManagementService } from "./sim-management.service";
|
import { SimManagementService } from "./sim-management.service";
|
||||||
|
import { SimUsageStoreService } from "./sim-usage-store.service";
|
||||||
import { WhmcsModule } from "../vendors/whmcs/whmcs.module";
|
import { WhmcsModule } from "../vendors/whmcs/whmcs.module";
|
||||||
import { MappingsModule } from "../mappings/mappings.module";
|
import { MappingsModule } from "../mappings/mappings.module";
|
||||||
import { FreebititModule } from "../vendors/freebit/freebit.module";
|
import { FreebititModule } from "../vendors/freebit/freebit.module";
|
||||||
@ -9,6 +10,6 @@ import { FreebititModule } from "../vendors/freebit/freebit.module";
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [WhmcsModule, MappingsModule, FreebititModule],
|
imports: [WhmcsModule, MappingsModule, FreebititModule],
|
||||||
controllers: [SubscriptionsController],
|
controllers: [SubscriptionsController],
|
||||||
providers: [SubscriptionsService, SimManagementService],
|
providers: [SubscriptionsService, SimManagementService, SimUsageStoreService],
|
||||||
})
|
})
|
||||||
export class SubscriptionsModule {}
|
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>
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<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>
|
<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>}
|
{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>}
|
{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>
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<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>
|
<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>}
|
{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>}
|
{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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveInfo('cancel');
|
setActiveInfo('cancel');
|
||||||
// keep inline confirm for cancel to avoid accidental navigation
|
try {
|
||||||
setShowCancelConfirm(true);
|
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
||||||
|
} catch {
|
||||||
|
// Fallback to inline confirm if router not available
|
||||||
|
setShowCancelConfirm(true);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
disabled={!canCancel || loading !== null}
|
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 ${
|
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