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:
@@ -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 (1M–10M 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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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' },
|
||||
|
||||
70
apps/web/lib/vietnam-geo.ts
Normal file
70
apps/web/lib/vietnam-geo.ts
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UsageRecord_subscriptionId_metric_periodStart_periodEnd_key" ON "UsageRecord"("subscriptionId", "metric", "periodStart", "periodEnd");
|
||||
@@ -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';
|
||||
@@ -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?
|
||||
|
||||
Reference in New Issue
Block a user