fix(subscriptions): atomic UsageRecord metering to prevent quota bypass

- Add @@unique([subscriptionId, metric, periodStart, periodEnd]) constraint
  to UsageRecord model with corresponding migration
- Replace racy findFirst+update/create pattern with Prisma upsert using
  INSERT ON CONFLICT DO UPDATE SET count = count + delta
- Fix CheckQuotaHandler to use period-scoped findUnique instead of
  unscoped findFirst, preventing stale cross-period reads
- Update tests to reflect atomic upsert pattern

Closes GOO-4

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-22 23:22:59 +07:00
parent 65bd641e1f
commit ee6d6d4c17
16 changed files with 180 additions and 79 deletions

View File

@@ -18,6 +18,13 @@ const DEFAULT_RANGES: Record<PropertyType, { min: number; max: number }> = {
LAND: { min: 5_000_000, max: 800_000_000 },
OFFICE: { min: 10_000_000, max: 300_000_000 },
SHOPHOUSE: { min: 30_000_000, max: 600_000_000 },
// Phòng trọ: priced per month (1M10M VND), stored as total price (not per-m²).
// Range reflects typical HCMC room rental market 2024-2026.
ROOM_RENTAL: { min: 1_000_000, max: 10_000_000 },
// Condotel: mixed-use hotel/condo; higher-end per-m² due to resort factor.
CONDOTEL: { min: 20_000_000, max: 300_000_000 },
// Serviced apartment: furnished with hotel-style services; premium over standard apartments.
SERVICED_APARTMENT: { min: 20_000_000, max: 250_000_000 },
};
/** Multiplier to widen default ranges for suspicious-but-not-invalid detection */

View File

@@ -24,6 +24,7 @@ describe('CheckQuotaHandler', () => {
},
usageRecord: {
findFirst: vi.fn(),
findUnique: vi.fn(),
},
};
@@ -33,7 +34,9 @@ describe('CheckQuotaHandler', () => {
invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
};
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any);
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CheckQuotaHandler(mockRepo as any, mockPrisma, mockCache as any, mockLogger as any);
});
it('returns quota for active subscription', async () => {
@@ -48,7 +51,7 @@ describe('CheckQuotaHandler', () => {
maxListings: 50,
maxSavedSearches: 10,
});
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 15 });
mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 15 });
const query = new CheckQuotaQuery('user-1', 'listings_created');
const result = await handler.execute(query);
@@ -71,7 +74,7 @@ describe('CheckQuotaHandler', () => {
id: 'plan-1',
maxListings: 5,
});
mockPrisma.usageRecord.findFirst.mockResolvedValue({ count: 5 });
mockPrisma.usageRecord.findUnique.mockResolvedValue({ count: 5 });
const query = new CheckQuotaQuery('user-1', 'listings_created');
const result = await handler.execute(query);

View File

@@ -28,9 +28,7 @@ describe('MeterUsageHandler', () => {
mockPrisma = {
usageRecord: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
};
@@ -50,11 +48,10 @@ describe('MeterUsageHandler', () => {
);
});
it('creates new usage record when none exists', async () => {
it('creates new usage record via atomic upsert when none exists', async () => {
const subscription = createActiveSubscription();
mockRepo.findByUserId.mockResolvedValue(subscription);
mockPrisma.usageRecord.findFirst.mockResolvedValue(null);
mockPrisma.usageRecord.create.mockResolvedValue({
mockPrisma.usageRecord.upsert.mockResolvedValue({
id: 'usage-1',
metric: 'listings_created',
count: 3,
@@ -68,17 +65,30 @@ describe('MeterUsageHandler', () => {
expect(result.usageRecordId).toBe('usage-1');
expect(result.metric).toBe('listings_created');
expect(result.count).toBe(3);
expect(mockPrisma.usageRecord.create).toHaveBeenCalledTimes(1);
expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledWith({
where: {
subscriptionId_metric_periodStart_periodEnd: {
subscriptionId: subscription.id,
metric: 'listings_created',
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
},
update: { count: { increment: 3 } },
create: {
subscriptionId: subscription.id,
metric: 'listings_created',
count: 3,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
});
});
it('increments existing usage record', async () => {
it('increments existing usage record via atomic upsert', async () => {
const subscription = createActiveSubscription();
mockRepo.findByUserId.mockResolvedValue(subscription);
mockPrisma.usageRecord.findFirst.mockResolvedValue({
id: 'usage-1',
count: 5,
});
mockPrisma.usageRecord.update.mockResolvedValue({
mockPrisma.usageRecord.upsert.mockResolvedValue({
id: 'usage-1',
metric: 'listings_created',
count: 8,
@@ -90,17 +100,13 @@ describe('MeterUsageHandler', () => {
const result = await handler.execute(command);
expect(result.count).toBe(8);
expect(mockPrisma.usageRecord.update).toHaveBeenCalledWith({
where: { id: 'usage-1' },
data: { count: 8 },
});
expect(mockPrisma.usageRecord.upsert).toHaveBeenCalledTimes(1);
});
it('invalidates quota cache after metering usage', async () => {
const subscription = createActiveSubscription();
mockRepo.findByUserId.mockResolvedValue(subscription);
mockPrisma.usageRecord.findFirst.mockResolvedValue(null);
mockPrisma.usageRecord.create.mockResolvedValue({
mockPrisma.usageRecord.upsert.mockResolvedValue({
id: 'usage-1',
metric: 'listings_created',
count: 1,

View File

@@ -40,34 +40,28 @@ export class MeterUsageHandler implements ICommandHandler<MeterUsageCommand> {
throw new ValidationException('Subscription không ở trạng thái hoạt động');
}
// Upsert usage record for current period + metric
const existing = await this.prisma.usageRecord.findFirst({
// Atomic upsert using the @@unique constraint to prevent race conditions
const usageRecord = await this.prisma.usageRecord.upsert({
where: {
subscriptionId_metric_periodStart_periodEnd: {
subscriptionId: subscription.id,
metric: command.metric,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
},
update: {
count: { increment: command.count },
},
create: {
subscriptionId: subscription.id,
metric: command.metric,
count: command.count,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
});
let usageRecord;
if (existing) {
usageRecord = await this.prisma.usageRecord.update({
where: { id: existing.id },
data: { count: existing.count + command.count },
});
} else {
usageRecord = await this.prisma.usageRecord.create({
data: {
subscriptionId: subscription.id,
metric: command.metric,
count: command.count,
periodStart: subscription.currentPeriodStart,
periodEnd: subscription.currentPeriodEnd,
},
});
}
// Invalidate cached quota for this user + metric
await this.cache.invalidate(
CacheService.buildKey(CachePrefix.USER_QUOTA, command.userId, command.metric),

View File

@@ -76,13 +76,15 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
throw new NotFoundException('Plan', subscription.planId);
}
return this.checkAgainstPlan(plan, metric, subscription.id);
return this.checkAgainstPlan(plan, metric, subscription.id, subscription.currentPeriodStart, subscription.currentPeriodEnd);
}
private async checkAgainstPlan(
plan: Plan,
metric: string,
subscriptionId: string | null,
periodStart?: Date,
periodEnd?: Date,
): Promise<QuotaCheckResult> {
const planField = METRIC_TO_PLAN_FIELD[metric];
@@ -93,9 +95,22 @@ export class CheckQuotaHandler implements IQueryHandler<CheckQuotaQuery> {
const limit = plan[planField] as number;
// Get current usage
// Get current period usage (period-scoped to prevent stale reads)
let used = 0;
if (subscriptionId) {
if (subscriptionId && periodStart && periodEnd) {
const usageRecord = await this.prisma.usageRecord.findUnique({
where: {
subscriptionId_metric_periodStart_periodEnd: {
subscriptionId,
metric,
periodStart,
periodEnd,
},
},
});
used = usageRecord?.count ?? 0;
} else if (subscriptionId) {
// Fallback for free tier (no subscription period)
const usageRecord = await this.prisma.usageRecord.findFirst({
where: {
subscriptionId,

View File

@@ -302,11 +302,11 @@ export default function AppDashboardLayout({ children }: { children: React.React
// TODO: thay thế bằng dữ liệu thực từ /analytics/districts khi API sẵn sàng (TEC-3047)
const tickerItems: TickerItem[] = [
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' },
{ id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' },
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'tanbinh', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
];

View File

@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useGenerateReport } from '@/lib/hooks/use-reports';
import type { ReportType } from '@/lib/reports-api';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
// ─── Constants ─────────────────────────────────────────
@@ -18,12 +19,7 @@ const PROVINCES = [
'Hưng Yên', 'Quảng Ninh', 'Thái Nguyên', 'Vĩnh Phúc', 'Cần Thơ',
];
const HCM_DISTRICTS = [
'Quận 1', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
'Bình Tân', 'Nhà Bè', 'Hóc Môn', 'Củ Chi', 'Cần Giờ',
];
const HCM_DISTRICTS_LIST = HCM_DISTRICTS;
const PROPERTY_TYPES = [
{ value: 'APARTMENT', label: 'Căn hộ' },
@@ -248,7 +244,7 @@ export default function TaoMoiPage() {
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="">Chọn quận/huyện</option>
{HCM_DISTRICTS.map((d) => (
{HCM_DISTRICTS_LIST.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>
@@ -302,7 +298,7 @@ export default function TaoMoiPage() {
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="">Chọn quận/huyện</option>
{HCM_DISTRICTS.map((d) => (
{HCM_DISTRICTS_LIST.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>

View File

@@ -14,15 +14,10 @@ import {
STATUS_LABELS,
} from '@/lib/chuyen-nhuong-api';
import { useTransferListingsSearch } from '@/lib/hooks/use-chuyen-nhuong';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
const PAGE_SIZE = 12;
const DISTRICTS = [
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12', 'Bình Thạnh',
'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
];
export default function ChuyenNhuongPage() {
const [filters, setFilters] = React.useState<SearchTransferListingsParams>({
page: 1,
@@ -93,7 +88,7 @@ export default function ChuyenNhuongPage() {
aria-label="Quận/Huyện"
>
<option value="">Quận/Huyện</option>
{DISTRICTS.map((d) => (
{HCM_DISTRICTS.map((d) => (
<option key={d} value={d}>{d}</option>
))}
</select>

View File

@@ -81,11 +81,11 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
const tickerItems: TickerItem[] = [
{ id: 'q1', label: 'Quận 1', changePercent: 2.4, direction: 'up' },
{ id: 'q2', label: 'Quận 2', changePercent: -0.8, direction: 'down' },
{ id: 'q2', label: 'Thành phố Thủ Đức', changePercent: -0.8, direction: 'down' },
{ id: 'q3', label: 'Quận 3', changePercent: 1.1, direction: 'up' },
{ id: 'q7', label: 'Quận 7', changePercent: 3.2, direction: 'up' },
{ id: 'binhthanh', label: 'Bình Thạnh', changePercent: 0.0, direction: 'neutral' },
{ id: 'thuduc', label: 'Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'thuduc', label: 'Thành phố Thủ Đức', changePercent: 1.7, direction: 'up' },
{ id: 'tanbinhdistrict', label: 'Tân Bình', changePercent: -1.3, direction: 'down' },
{ id: 'phuninh', label: 'Phú Nhuận', changePercent: 0.5, direction: 'up' },
];

View File

@@ -19,17 +19,13 @@ import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import { useListingsSearch } from '@/lib/hooks/use-listings';
import type { ListingDetail, PropertyType, TransactionType } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings';
import { HCM_DISTRICTS } from '@/lib/vietnam-geo';
// ---------------------------------------------------------------------------
// Hằng số
// ---------------------------------------------------------------------------
const DISTRICTS = [
'Quận 1', 'Quận 2', 'Quận 3', 'Quận 4', 'Quận 5', 'Quận 6', 'Quận 7',
'Quận 8', 'Quận 9', 'Quận 10', 'Quận 11', 'Quận 12',
'Bình Thạnh', 'Gò Vấp', 'Phú Nhuận', 'Tân Bình', 'Tân Phú', 'Thủ Đức',
'Bình Chánh', 'Hóc Môn', 'Củ Chi', 'Nhà Bè', 'Cần Giờ',
];
const DISTRICTS = HCM_DISTRICTS;
const PRICE_RANGES = [
{ label: 'Dưới 1 tỷ', min: '', max: '1000000000' },

View File

@@ -0,0 +1,70 @@
/**
* vietnam-geo.ts
* Centralized Vietnamese administrative geography data.
*
* NOTE: As of 01/01/2021, Quận 2, Quận 9 and the old Thủ Đức district were
* merged into Thành phố Thủ Đức. Use HCM_DISTRICTS for all district-picker UIs.
*/
/** Current Ho Chi Minh City districts / city-level subdivisions (post-2021). */
export const HCM_DISTRICTS: readonly string[] = [
// Inner urban districts (quận nội thành)
'Quận 1',
'Quận 3',
'Quận 4',
'Quận 5',
'Quận 6',
'Quận 7',
'Quận 8',
'Quận 10',
'Quận 11',
'Quận 12',
// Thu Duc city (merged from former Quận 2, Quận 9, and Thủ Đức district)
'Thành phố Thủ Đức',
// Suburban districts (quận ngoại thành)
'Bình Tân',
'Bình Thạnh',
'Gò Vấp',
'Phú Nhuận',
'Tân Bình',
'Tân Phú',
// Rural districts (huyện)
'Bình Chánh',
'Cần Giờ',
'Củ Chi',
'Hóc Môn',
'Nhà Bè',
] as const;
/** Major Vietnamese provinces / centrally-administered municipalities. */
export const PROVINCES: readonly string[] = [
'Hồ Chí Minh',
'Hà Nội',
'Đà Nẵng',
'Bình Dương',
'Đồng Nai',
'Long An',
'Bà Rịa - Vũng Tàu',
'Bắc Ninh',
'Hải Phòng',
'Hải Dương',
'Hưng Yên',
'Quảng Ninh',
'Thái Nguyên',
'Vĩnh Phúc',
'Cần Thơ',
] as const;
/** Major cities shown in city pickers across the platform. */
export const MAJOR_CITIES: readonly string[] = [
'Hồ Chí Minh',
'Hà Nội',
'Đà Nẵng',
'Nha Trang',
'Cần Thơ',
'Hải Phòng',
'Bình Dương',
'Đồng Nai',
'Long An',
'Bà Rịa - Vũng Tàu',
] as const;

View File

@@ -126,7 +126,10 @@
"VILLA": "Villa",
"LAND": "Land",
"OFFICE": "Office",
"SHOPHOUSE": "Shophouse"
"SHOPHOUSE": "Shophouse",
"ROOM_RENTAL": "Room Rental",
"CONDOTEL": "Condotel",
"SERVICED_APARTMENT": "Serviced Apartment"
},
"transactionTypes": {
"SALE": "Sale",

View File

@@ -126,7 +126,10 @@
"VILLA": "Biệt thự",
"LAND": "Đất nền",
"OFFICE": "Văn phòng",
"SHOPHOUSE": "Shophouse"
"SHOPHOUSE": "Shophouse",
"ROOM_RENTAL": "Phòng trọ",
"CONDOTEL": "Condotel",
"SERVICED_APARTMENT": "Căn hộ dịch vụ"
},
"transactionTypes": {
"SALE": "Bán",

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE UNIQUE INDEX "UsageRecord_subscriptionId_metric_periodStart_periodEnd_key" ON "UsageRecord"("subscriptionId", "metric", "periodStart", "periodEnd");

View File

@@ -0,0 +1,7 @@
-- AlterEnum
-- Add ROOM_RENTAL, CONDOTEL, and SERVICED_APARTMENT to the PropertyType enum.
-- These new values support phòng trọ (room rentals), condotels, and serviced apartment listings.
ALTER TYPE "PropertyType" ADD VALUE 'ROOM_RENTAL';
ALTER TYPE "PropertyType" ADD VALUE 'CONDOTEL';
ALTER TYPE "PropertyType" ADD VALUE 'SERVICED_APARTMENT';

View File

@@ -265,6 +265,9 @@ enum PropertyType {
LAND
OFFICE
SHOPHOUSE
ROOM_RENTAL
CONDOTEL
SERVICED_APARTMENT
}
enum TransactionType {
@@ -757,6 +760,7 @@ model UsageRecord {
periodStart DateTime
periodEnd DateTime
@@unique([subscriptionId, metric, periodStart, periodEnd])
@@index([subscriptionId, metric])
}
@@ -1064,10 +1068,10 @@ model IndustrialPark {
remainingAreaHa Float
tenantCount Int @default(0)
establishedYear Int?
landRentUsdM2Year Float?
rbfRentUsdM2Month Float?
rbwRentUsdM2Month Float?
managementFeeUsd Float?
landRentUsdM2Year Decimal? @db.Decimal(18, 4)
rbfRentUsdM2Month Decimal? @db.Decimal(18, 4)
rbwRentUsdM2Month Decimal? @db.Decimal(18, 4)
managementFeeUsd Decimal? @db.Decimal(18, 4)
infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire }
connectivity Json? // { nearestPort, airport, highway, railway, seaport }
incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone }
@@ -1121,10 +1125,10 @@ model IndustrialListing {
hasMezzanine Boolean @default(false)
hasOfficeArea Boolean @default(false)
officeAreaM2 Float?
priceUsdM2 Float?
priceUsdM2 Decimal? @db.Decimal(18, 4)
pricingUnit String? // "usd/m2/month", "usd/m2/year"
totalLeasePrice Float?
managementFee Float?
totalLeasePrice Decimal? @db.Decimal(18, 4)
managementFee Decimal? @db.Decimal(18, 4)
depositMonths Int?
minLeaseYears Int?
maxLeaseYears Int?