refactor: simplify pricing tables to USD-only

- Remove KRW pricing calculations from all pricing tables
- Simplify pricing table to store only wholesale USD prices
- Simplify anvil_pricing to store only retail USD prices
- Remove KRW environment variables (KRW_EXCHANGE_RATE, KRW_MARGIN_RATE)
- Remove KRW functions from constants.ts
- Update GPU/G8/VPU pricing repositories to match
- Add Anvil tables and repositories for branded product support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-25 21:16:25 +09:00
parent a2133ae5c9
commit 9f3d3a245a
21 changed files with 1952 additions and 197 deletions

View File

@@ -226,88 +226,52 @@ export const REQUEST_LIMITS = {
} as const;
// ============================================================
// KRW Pricing Configuration
// USD Retail Pricing Configuration
// ============================================================
/**
* Default KRW (Korean Won) pricing configuration
* Default USD retail pricing configuration
*
* These defaults are used when environment variables are not set.
* These defaults are used to calculate retail prices from wholesale prices.
* Calculation formula:
* KRW = USD × VAT (1.1) × Markup (1.1) × Exchange Rate (1450)
* KRW = USD × 1754.5
* Retail = Wholesale × Margin (1.1) × VAT (1.1)
* Retail = Wholesale × 1.21
*/
export const KRW_PRICING_DEFAULTS = {
export const USD_RETAIL_DEFAULTS = {
/** Margin multiplier (10% margin) */
MARGIN_MULTIPLIER: 1.1,
/** VAT multiplier (10% VAT) */
VAT_MULTIPLIER: 1.1,
/** Markup multiplier (10% markup) */
MARKUP_MULTIPLIER: 1.1,
/** USD to KRW exchange rate */
EXCHANGE_RATE: 1450,
/** Total multiplier (margin × VAT) */
TOTAL_MULTIPLIER: 1.21,
} as const;
/**
* KRW pricing configuration interface
*/
export interface KRWConfig {
exchangeRate: number;
vatRate: number;
markupRate: number;
totalMultiplier: number;
}
/**
* Get KRW pricing configuration from environment variables
* Falls back to default values if env vars are not set
* Calculate USD retail hourly price from wholesale price
* Applies margin and VAT
*
* @param env - Cloudflare Worker environment
* @returns KRW pricing configuration
*/
export function getKRWConfig(env?: { KRW_EXCHANGE_RATE?: string; KRW_VAT_RATE?: string; KRW_MARKUP_RATE?: string }): KRWConfig {
const exchangeRate = env?.KRW_EXCHANGE_RATE ? parseFloat(env.KRW_EXCHANGE_RATE) : KRW_PRICING_DEFAULTS.EXCHANGE_RATE;
const vatRate = env?.KRW_VAT_RATE ? parseFloat(env.KRW_VAT_RATE) : KRW_PRICING_DEFAULTS.VAT_MULTIPLIER;
const markupRate = env?.KRW_MARKUP_RATE ? parseFloat(env.KRW_MARKUP_RATE) : KRW_PRICING_DEFAULTS.MARKUP_MULTIPLIER;
return {
exchangeRate,
vatRate,
markupRate,
totalMultiplier: vatRate * markupRate * exchangeRate,
};
}
/**
* Calculate KRW hourly price from USD price
* Applies VAT, markup, and exchange rate conversion
*
* @param usd - Hourly price in USD
* @param env - Optional environment for custom rates (uses defaults if not provided)
* @returns Price in KRW, rounded to nearest 1 KRW (minimum 1 KRW)
* @param wholesale - Wholesale hourly price in USD
* @returns Retail price in USD, rounded to 4 decimal places
*
* @example
* calculateKRWHourly(0.0075) // Returns 13 (with defaults)
* calculateKRWHourly(0.144) // Returns 253 (with defaults)
* calculateRetailHourly(0.0075) // Returns 0.0091 (with defaults)
* calculateRetailHourly(0.144) // Returns 0.1742 (with defaults)
*/
export function calculateKRWHourly(usd: number, env?: { KRW_EXCHANGE_RATE?: string; KRW_VAT_RATE?: string; KRW_MARKUP_RATE?: string }): number {
const config = getKRWConfig(env);
const krw = Math.round(usd * config.totalMultiplier);
return Math.max(krw, 1);
export function calculateRetailHourly(wholesale: number): number {
return Math.round(wholesale * USD_RETAIL_DEFAULTS.TOTAL_MULTIPLIER * 10000) / 10000;
}
/**
* Calculate KRW monthly price from USD price
* Applies VAT, markup, and exchange rate conversion
* Calculate USD retail monthly price from wholesale price
* Applies margin and VAT
*
* @param usd - Monthly price in USD
* @param env - Optional environment for custom rates (uses defaults if not provided)
* @returns Price in KRW, rounded to nearest 100 KRW (minimum 100 KRW)
* @param wholesale - Wholesale monthly price in USD
* @returns Retail price in USD, rounded to nearest $1
*
* @example
* calculateKRWMonthly(5) // Returns 8800 (with defaults)
* calculateKRWMonthly(96) // Returns 168400 (with defaults)
* calculateRetailMonthly(5) // Returns 6 (with defaults)
* calculateRetailMonthly(96) // Returns 116 (with defaults)
*/
export function calculateKRWMonthly(usd: number, env?: { KRW_EXCHANGE_RATE?: string; KRW_VAT_RATE?: string; KRW_MARKUP_RATE?: string }): number {
const config = getKRWConfig(env);
const krw = Math.round(usd * config.totalMultiplier / 100) * 100;
return Math.max(krw, 100);
export function calculateRetailMonthly(wholesale: number): number {
return Math.round(wholesale * USD_RETAIL_DEFAULTS.TOTAL_MULTIPLIER);
}

View File

@@ -0,0 +1,267 @@
/**
* Anvil Instances Repository
* Handles CRUD operations for Anvil-branded instance specifications
*/
import { BaseRepository } from './base';
import { AnvilInstance, AnvilInstanceInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
export class AnvilInstancesRepository extends BaseRepository<AnvilInstance> {
protected tableName = 'anvil_instances';
protected logger = createLogger('[AnvilInstancesRepository]');
protected allowedColumns = [
'name',
'display_name',
'category',
'vcpus',
'memory_gb',
'disk_gb',
'transfer_tb',
'network_gbps',
'gpu_model',
'gpu_vram_gb',
'active',
];
/**
* Find an instance by name (e.g., "anvil-1g-1c")
*/
async findByName(name: string): Promise<AnvilInstance | null> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_instances WHERE name = ?')
.bind(name)
.first<AnvilInstance>();
return result || null;
} catch (error) {
this.logger.error('findByName failed', {
name,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find Anvil instance by name: ${name}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find all instances by category
*/
async findByCategory(category: 'vm' | 'gpu' | 'g8' | 'vpu'): Promise<AnvilInstance[]> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_instances WHERE category = ? ORDER BY name')
.bind(category)
.all<AnvilInstance>();
return result.results;
} catch (error) {
this.logger.error('findByCategory failed', {
category,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find Anvil instances by category: ${category}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Get all active instances
*/
async findActive(category?: 'vm' | 'gpu' | 'g8' | 'vpu'): Promise<AnvilInstance[]> {
try {
let query = 'SELECT * FROM anvil_instances WHERE active = 1';
const params: (string | number | boolean | null)[] = [];
if (category) {
query += ' AND category = ?';
params.push(category);
}
query += ' ORDER BY name';
const result = await this.db
.prepare(query)
.bind(...params)
.all<AnvilInstance>();
return result.results;
} catch (error) {
this.logger.error('findActive failed', {
category,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to find active Anvil instances',
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Search instances by resource requirements
*/
async searchByResources(
minVcpus?: number,
minMemoryGb?: number,
minDiskGb?: number,
category?: 'vm' | 'gpu' | 'g8' | 'vpu'
): Promise<AnvilInstance[]> {
try {
const conditions: string[] = ['active = 1'];
const params: (string | number | boolean | null)[] = [];
if (minVcpus !== undefined) {
conditions.push('vcpus >= ?');
params.push(minVcpus);
}
if (minMemoryGb !== undefined) {
conditions.push('memory_gb >= ?');
params.push(minMemoryGb);
}
if (minDiskGb !== undefined) {
conditions.push('disk_gb >= ?');
params.push(minDiskGb);
}
if (category) {
conditions.push('category = ?');
params.push(category);
}
const query = 'SELECT * FROM anvil_instances WHERE ' + conditions.join(' AND ') + ' ORDER BY memory_gb, vcpus';
const result = await this.db
.prepare(query)
.bind(...params)
.all<AnvilInstance>();
return result.results;
} catch (error) {
this.logger.error('searchByResources failed', {
minVcpus,
minMemoryGb,
minDiskGb,
category,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to search Anvil instances by resources',
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Update instance active status
*/
async updateActive(id: number, active: boolean): Promise<AnvilInstance> {
try {
const result = await this.db
.prepare('UPDATE anvil_instances SET active = ? WHERE id = ? RETURNING *')
.bind(active ? 1 : 0, id)
.first<AnvilInstance>();
if (!result) {
throw new RepositoryError(
`Anvil instance not found: ${id}`,
ErrorCodes.NOT_FOUND
);
}
return result;
} catch (error) {
this.logger.error('updateActive failed', {
id,
active,
error: error instanceof Error ? error.message : 'Unknown error'
});
if (error instanceof RepositoryError) {
throw error;
}
throw new RepositoryError(
`Failed to update Anvil instance active status: ${id}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Bulk upsert instances
* Uses batch operations for efficiency
*/
async upsertMany(instances: AnvilInstanceInput[]): Promise<number> {
if (instances.length === 0) {
return 0;
}
try {
const statements = instances.map((instance) => {
return this.db.prepare(
`INSERT INTO anvil_instances (
name, display_name, category, vcpus, memory_gb, disk_gb,
transfer_tb, network_gbps, gpu_model, gpu_vram_gb, active
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name)
DO UPDATE SET
display_name = excluded.display_name,
category = excluded.category,
vcpus = excluded.vcpus,
memory_gb = excluded.memory_gb,
disk_gb = excluded.disk_gb,
transfer_tb = excluded.transfer_tb,
network_gbps = excluded.network_gbps,
gpu_model = excluded.gpu_model,
gpu_vram_gb = excluded.gpu_vram_gb,
active = excluded.active`
).bind(
instance.name,
instance.display_name,
instance.category,
instance.vcpus,
instance.memory_gb,
instance.disk_gb,
instance.transfer_tb,
instance.network_gbps,
instance.gpu_model,
instance.gpu_vram_gb,
instance.active
);
});
const results = await this.executeBatch(statements);
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted Anvil instances', { count: successCount });
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
count: instances.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to upsert Anvil instances',
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -0,0 +1,210 @@
/**
* Anvil Pricing Repository
* Handles CRUD operations for Anvil retail pricing (USD only)
*/
import { BaseRepository } from './base';
import { AnvilPricing, AnvilPricingInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
export class AnvilPricingRepository extends BaseRepository<AnvilPricing> {
protected tableName = 'anvil_pricing';
protected logger = createLogger('[AnvilPricingRepository]');
protected allowedColumns = [
'anvil_instance_id',
'anvil_region_id',
'hourly_price',
'monthly_price',
'source_instance_id',
];
constructor(db: D1Database) {
super(db);
}
/**
* Find pricing records for a specific instance
*/
async findByInstance(anvilInstanceId: number): Promise<AnvilPricing[]> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_pricing WHERE anvil_instance_id = ?')
.bind(anvilInstanceId)
.all<AnvilPricing>();
return result.results;
} catch (error) {
this.logger.error('findByInstance failed', {
anvilInstanceId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for Anvil instance: ${anvilInstanceId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find pricing records for a specific region
*/
async findByRegion(anvilRegionId: number): Promise<AnvilPricing[]> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_pricing WHERE anvil_region_id = ?')
.bind(anvilRegionId)
.all<AnvilPricing>();
return result.results;
} catch (error) {
this.logger.error('findByRegion failed', {
anvilRegionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for Anvil region: ${anvilRegionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find a specific pricing record by instance and region
*/
async findByInstanceAndRegion(
anvilInstanceId: number,
anvilRegionId: number
): Promise<AnvilPricing | null> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_pricing WHERE anvil_instance_id = ? AND anvil_region_id = ?')
.bind(anvilInstanceId, anvilRegionId)
.first<AnvilPricing>();
return result || null;
} catch (error) {
this.logger.error('findByInstanceAndRegion failed', {
anvilInstanceId,
anvilRegionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find pricing for Anvil instance ${anvilInstanceId} in region ${anvilRegionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Search pricing by price range
*/
async searchByPriceRange(
minHourly?: number,
maxHourly?: number,
minMonthly?: number,
maxMonthly?: number
): Promise<AnvilPricing[]> {
try {
const conditions: string[] = [];
const params: (string | number | boolean | null)[] = [];
if (minHourly !== undefined) {
conditions.push('hourly_price >= ?');
params.push(minHourly);
}
if (maxHourly !== undefined) {
conditions.push('hourly_price <= ?');
params.push(maxHourly);
}
if (minMonthly !== undefined) {
conditions.push('monthly_price >= ?');
params.push(minMonthly);
}
if (maxMonthly !== undefined) {
conditions.push('monthly_price <= ?');
params.push(maxMonthly);
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
const query = `SELECT * FROM anvil_pricing ${whereClause}`;
const result = await this.db
.prepare(query)
.bind(...params)
.all<AnvilPricing>();
return result.results;
} catch (error) {
this.logger.error('searchByPriceRange failed', {
minHourly,
maxHourly,
minMonthly,
maxMonthly,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to search Anvil pricing by price range',
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Bulk upsert pricing records
* Uses batch operations for efficiency
*/
async upsertMany(pricing: AnvilPricingInput[]): Promise<number> {
if (pricing.length === 0) {
return 0;
}
try {
const statements = pricing.map((price) => {
return this.db.prepare(
`INSERT INTO anvil_pricing (
anvil_instance_id, anvil_region_id, hourly_price, monthly_price,
source_instance_id
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(anvil_instance_id, anvil_region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
source_instance_id = excluded.source_instance_id`
).bind(
price.anvil_instance_id,
price.anvil_region_id,
price.hourly_price,
price.monthly_price,
price.source_instance_id
);
});
const results = await this.executeBatch(statements);
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted Anvil pricing records', { count: successCount });
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
count: pricing.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to upsert Anvil pricing records',
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -0,0 +1,163 @@
/**
* Anvil Regions Repository Tests
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AnvilRegionsRepository } from './anvil-regions';
import type { AnvilRegion } from '../types';
const createMockD1Database = () => {
return {
prepare: vi.fn(),
dump: vi.fn(),
batch: vi.fn(),
exec: vi.fn(),
withSession: vi.fn(),
};
};
const createMockD1Result = <T>(results: T[]) => ({
results,
success: true,
meta: {},
});
describe('AnvilRegionsRepository', () => {
let db: D1Database;
let repository: AnvilRegionsRepository;
beforeEach(() => {
db = createMockD1Database();
repository = new AnvilRegionsRepository(db);
});
describe('findByName', () => {
it('should find a region by name', async () => {
const mockRegion: AnvilRegion = {
id: 1,
name: 'anvil-tyo1',
display_name: 'Tokyo 1',
country_code: 'JP',
source_provider: 'linode',
source_region_code: 'ap-northeast',
source_region_id: 26,
active: 1,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
db.prepare = () => ({
bind: () => ({
first: async () => mockRegion,
}),
}) as any;
const result = await repository.findByName('anvil-tyo1');
expect(result).toEqual(mockRegion);
});
it('should return null if region not found', async () => {
db.prepare = () => ({
bind: () => ({
first: async () => null,
}),
}) as any;
const result = await repository.findByName('nonexistent');
expect(result).toBeNull();
});
});
describe('findByCountry', () => {
it('should find all regions for a country', async () => {
const mockRegions: AnvilRegion[] = [
{
id: 1,
name: 'anvil-tyo1',
display_name: 'Tokyo 1',
country_code: 'JP',
source_provider: 'linode',
source_region_code: 'ap-northeast',
source_region_id: 26,
active: 1,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
{
id: 2,
name: 'anvil-osa1',
display_name: 'Osaka 1',
country_code: 'JP',
source_provider: 'linode',
source_region_code: 'jp-osa',
source_region_id: 13,
active: 1,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
db.prepare = () => ({
bind: () => ({
all: async () => createMockD1Result(mockRegions),
}),
}) as any;
const result = await repository.findByCountry('JP');
expect(result).toEqual(mockRegions);
expect(result).toHaveLength(2);
});
});
describe('findActive', () => {
it('should find all active regions', async () => {
const mockRegions: AnvilRegion[] = [
{
id: 1,
name: 'anvil-tyo1',
display_name: 'Tokyo 1',
country_code: 'JP',
source_provider: 'linode',
source_region_code: 'ap-northeast',
source_region_id: 26,
active: 1,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
];
db.prepare = () => ({
all: async () => createMockD1Result(mockRegions),
}) as any;
const result = await repository.findActive();
expect(result).toEqual(mockRegions);
});
});
describe('updateActive', () => {
it('should update region active status', async () => {
const mockRegion: AnvilRegion = {
id: 1,
name: 'anvil-tyo1',
display_name: 'Tokyo 1',
country_code: 'JP',
source_provider: 'linode',
source_region_code: 'ap-northeast',
source_region_id: 26,
active: 0,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
};
db.prepare = () => ({
bind: () => ({
first: async () => mockRegion,
}),
}) as any;
const result = await repository.updateActive(1, false);
expect(result.active).toBe(0);
});
});
});

View File

@@ -0,0 +1,210 @@
/**
* Anvil Regions Repository
* Handles CRUD operations for Anvil-branded regional datacenters
*/
import { BaseRepository } from './base';
import { AnvilRegion, AnvilRegionInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
export class AnvilRegionsRepository extends BaseRepository<AnvilRegion> {
protected tableName = 'anvil_regions';
protected logger = createLogger('[AnvilRegionsRepository]');
protected allowedColumns = [
'name',
'display_name',
'country_code',
'source_provider',
'source_region_code',
'source_region_id',
'active',
];
/**
* Find a region by name (e.g., "anvil-tyo1")
*/
async findByName(name: string): Promise<AnvilRegion | null> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_regions WHERE name = ?')
.bind(name)
.first<AnvilRegion>();
return result || null;
} catch (error) {
this.logger.error('findByName failed', {
name,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find Anvil region by name: ${name}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find all regions for a country
*/
async findByCountry(countryCode: string): Promise<AnvilRegion[]> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_regions WHERE country_code = ? ORDER BY name')
.bind(countryCode)
.all<AnvilRegion>();
return result.results;
} catch (error) {
this.logger.error('findByCountry failed', {
countryCode,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find Anvil regions for country: ${countryCode}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Find region by source region ID
*/
async findBySourceRegion(sourceRegionId: number): Promise<AnvilRegion | null> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_regions WHERE source_region_id = ?')
.bind(sourceRegionId)
.first<AnvilRegion>();
return result || null;
} catch (error) {
this.logger.error('findBySourceRegion failed', {
sourceRegionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find Anvil region by source region: ${sourceRegionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Get all active regions
*/
async findActive(): Promise<AnvilRegion[]> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_regions WHERE active = 1 ORDER BY name')
.all<AnvilRegion>();
return result.results;
} catch (error) {
this.logger.error('findActive failed', {
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to find active Anvil regions',
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Update region active status
*/
async updateActive(id: number, active: boolean): Promise<AnvilRegion> {
try {
const result = await this.db
.prepare('UPDATE anvil_regions SET active = ? WHERE id = ? RETURNING *')
.bind(active ? 1 : 0, id)
.first<AnvilRegion>();
if (!result) {
throw new RepositoryError(
`Anvil region not found: ${id}`,
ErrorCodes.NOT_FOUND
);
}
return result;
} catch (error) {
this.logger.error('updateActive failed', {
id,
active,
error: error instanceof Error ? error.message : 'Unknown error'
});
if (error instanceof RepositoryError) {
throw error;
}
throw new RepositoryError(
`Failed to update Anvil region active status: ${id}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Bulk upsert regions
* Uses batch operations for efficiency
*/
async upsertMany(regions: AnvilRegionInput[]): Promise<number> {
if (regions.length === 0) {
return 0;
}
try {
const statements = regions.map((region) => {
return this.db.prepare(
`INSERT INTO anvil_regions (
name, display_name, country_code, source_provider,
source_region_code, source_region_id, active
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(name)
DO UPDATE SET
display_name = excluded.display_name,
country_code = excluded.country_code,
source_provider = excluded.source_provider,
source_region_code = excluded.source_region_code,
source_region_id = excluded.source_region_id,
active = excluded.active`
).bind(
region.name,
region.display_name,
region.country_code,
region.source_provider,
region.source_region_code,
region.source_region_id,
region.active
);
});
const results = await this.executeBatch(statements);
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted Anvil regions', { count: successCount });
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
count: regions.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to upsert Anvil regions',
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -0,0 +1,91 @@
/**
* Anvil Transfer Pricing Repository
* Handles CRUD operations for data transfer pricing per region (USD only)
*/
import { BaseRepository } from './base';
import { AnvilTransferPricing, AnvilTransferPricingInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
export class AnvilTransferPricingRepository extends BaseRepository<AnvilTransferPricing> {
protected tableName = 'anvil_transfer_pricing';
protected logger = createLogger('[AnvilTransferPricingRepository]');
protected allowedColumns = [
'anvil_region_id',
'price_per_gb',
];
constructor(db: D1Database) {
super(db);
}
/**
* Find transfer pricing for a specific region
*/
async findByRegion(anvilRegionId: number): Promise<AnvilTransferPricing | null> {
try {
const result = await this.db
.prepare('SELECT * FROM anvil_transfer_pricing WHERE anvil_region_id = ?')
.bind(anvilRegionId)
.first<AnvilTransferPricing>();
return result || null;
} catch (error) {
this.logger.error('findByRegion failed', {
anvilRegionId,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
`Failed to find transfer pricing for Anvil region: ${anvilRegionId}`,
ErrorCodes.DATABASE_ERROR,
error
);
}
}
/**
* Bulk upsert transfer pricing records
* Uses batch operations for efficiency
*/
async upsertMany(pricing: AnvilTransferPricingInput[]): Promise<number> {
if (pricing.length === 0) {
return 0;
}
try {
const statements = pricing.map((price) => {
return this.db.prepare(
`INSERT INTO anvil_transfer_pricing (
anvil_region_id, price_per_gb
) VALUES (?, ?)
ON CONFLICT(anvil_region_id)
DO UPDATE SET
price_per_gb = excluded.price_per_gb`
).bind(
price.anvil_region_id,
price.price_per_gb
);
});
const results = await this.executeBatch(statements);
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted Anvil transfer pricing records', { count: successCount });
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {
count: pricing.length,
error: error instanceof Error ? error.message : 'Unknown error'
});
throw new RepositoryError(
'Failed to upsert Anvil transfer pricing records',
ErrorCodes.TRANSACTION_FAILED,
error
);
}
}
}

View File

@@ -4,9 +4,9 @@
*/
import { BaseRepository } from './base';
import { G8Pricing, G8PricingInput, RepositoryError, ErrorCodes, Env } from '../types';
import { G8Pricing, G8PricingInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
export class G8PricingRepository extends BaseRepository<G8Pricing> {
protected tableName = 'g8_pricing';
@@ -16,13 +16,13 @@ export class G8PricingRepository extends BaseRepository<G8Pricing> {
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'hourly_price_retail',
'monthly_price_retail',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
constructor(db: D1Database) {
super(db);
}
@@ -85,20 +85,21 @@ export class G8PricingRepository extends BaseRepository<G8Pricing> {
try {
const statements = pricingData.map((pricing) => {
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
const hourlyRetail = calculateRetailHourly(pricing.hourly_price);
const monthlyRetail = calculateRetailMonthly(pricing.monthly_price);
return this.db.prepare(
`INSERT INTO g8_pricing (
g8_instance_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
hourly_price_retail, monthly_price_retail,
currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(g8_instance_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
hourly_price_retail = excluded.hourly_price_retail,
monthly_price_retail = excluded.monthly_price_retail,
currency = excluded.currency,
available = excluded.available,
updated_at = datetime('now')`
@@ -107,21 +108,21 @@ export class G8PricingRepository extends BaseRepository<G8Pricing> {
pricing.region_id,
pricing.hourly_price,
pricing.monthly_price,
hourlyKrw,
monthlyKrw,
hourlyRetail,
monthlyRetail,
pricing.currency,
pricing.available
);
});
const results = await this.db.batch(statements);
const successCount = results.filter((r) => r.success).length;
const results = await this.executeBatch(statements);
this.logger.info('upsertMany completed', {
total: pricingData.length,
success: successCount,
});
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted G8 pricing records', { count: successCount });
return successCount;
} catch (error) {
this.logger.error('upsertMany failed', {

View File

@@ -4,9 +4,9 @@
*/
import { BaseRepository } from './base';
import { GpuPricing, GpuPricingInput, RepositoryError, ErrorCodes, Env } from '../types';
import { GpuPricing, GpuPricingInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
export class GpuPricingRepository extends BaseRepository<GpuPricing> {
protected tableName = 'gpu_pricing';
@@ -16,13 +16,13 @@ export class GpuPricingRepository extends BaseRepository<GpuPricing> {
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'hourly_price_retail',
'monthly_price_retail',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
constructor(db: D1Database) {
super(db);
}
@@ -113,20 +113,21 @@ export class GpuPricingRepository extends BaseRepository<GpuPricing> {
try {
const statements = pricingData.map((pricing) => {
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
const hourlyRetail = calculateRetailHourly(pricing.hourly_price);
const monthlyRetail = calculateRetailMonthly(pricing.monthly_price);
return this.db.prepare(
`INSERT INTO gpu_pricing (
gpu_instance_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
hourly_price_retail, monthly_price_retail,
currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(gpu_instance_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
hourly_price_retail = excluded.hourly_price_retail,
monthly_price_retail = excluded.monthly_price_retail,
currency = excluded.currency,
available = excluded.available`
).bind(
@@ -134,8 +135,8 @@ export class GpuPricingRepository extends BaseRepository<GpuPricing> {
pricing.region_id,
pricing.hourly_price,
pricing.monthly_price,
hourlyKrw,
monthlyKrw,
hourlyRetail,
monthlyRetail,
pricing.currency,
pricing.available
);

View File

@@ -14,6 +14,10 @@ export { G8InstancesRepository } from './g8-instances';
export { G8PricingRepository } from './g8-pricing';
export { VpuInstancesRepository } from './vpu-instances';
export { VpuPricingRepository } from './vpu-pricing';
export { AnvilRegionsRepository } from './anvil-regions';
export { AnvilInstancesRepository } from './anvil-instances';
export { AnvilPricingRepository } from './anvil-pricing';
export { AnvilTransferPricingRepository } from './anvil-transfer-pricing';
import { ProvidersRepository } from './providers';
import { RegionsRepository } from './regions';
@@ -25,6 +29,10 @@ import { G8InstancesRepository } from './g8-instances';
import { G8PricingRepository } from './g8-pricing';
import { VpuInstancesRepository } from './vpu-instances';
import { VpuPricingRepository } from './vpu-pricing';
import { AnvilRegionsRepository } from './anvil-regions';
import { AnvilInstancesRepository } from './anvil-instances';
import { AnvilPricingRepository } from './anvil-pricing';
import { AnvilTransferPricingRepository } from './anvil-transfer-pricing';
import type { Env } from '../types';
/**
@@ -42,6 +50,10 @@ export class RepositoryFactory {
private _g8Pricing?: G8PricingRepository;
private _vpuInstances?: VpuInstancesRepository;
private _vpuPricing?: VpuPricingRepository;
private _anvilRegions?: AnvilRegionsRepository;
private _anvilInstances?: AnvilInstancesRepository;
private _anvilPricing?: AnvilPricingRepository;
private _anvilTransferPricing?: AnvilTransferPricingRepository;
constructor(private _db: D1Database, private _env?: Env) {}
@@ -53,7 +65,7 @@ export class RepositoryFactory {
}
/**
* Access to environment variables for KRW pricing configuration
* Access to environment variables
*/
get env(): Env | undefined {
return this._env;
@@ -72,7 +84,7 @@ export class RepositoryFactory {
}
get pricing(): PricingRepository {
return this._pricing ??= new PricingRepository(this.db, this._env);
return this._pricing ??= new PricingRepository(this.db);
}
get gpuInstances(): GpuInstancesRepository {
@@ -80,7 +92,7 @@ export class RepositoryFactory {
}
get gpuPricing(): GpuPricingRepository {
return this._gpuPricing ??= new GpuPricingRepository(this.db, this._env);
return this._gpuPricing ??= new GpuPricingRepository(this.db);
}
get g8Instances(): G8InstancesRepository {
@@ -88,7 +100,7 @@ export class RepositoryFactory {
}
get g8Pricing(): G8PricingRepository {
return this._g8Pricing ??= new G8PricingRepository(this.db, this._env);
return this._g8Pricing ??= new G8PricingRepository(this.db);
}
get vpuInstances(): VpuInstancesRepository {
@@ -96,6 +108,22 @@ export class RepositoryFactory {
}
get vpuPricing(): VpuPricingRepository {
return this._vpuPricing ??= new VpuPricingRepository(this.db, this._env);
return this._vpuPricing ??= new VpuPricingRepository(this.db);
}
get anvilRegions(): AnvilRegionsRepository {
return this._anvilRegions ??= new AnvilRegionsRepository(this.db);
}
get anvilInstances(): AnvilInstancesRepository {
return this._anvilInstances ??= new AnvilInstancesRepository(this.db);
}
get anvilPricing(): AnvilPricingRepository {
return this._anvilPricing ??= new AnvilPricingRepository(this.db);
}
get anvilTransferPricing(): AnvilTransferPricingRepository {
return this._anvilTransferPricing ??= new AnvilTransferPricingRepository(this.db);
}
}

View File

@@ -4,9 +4,8 @@
*/
import { BaseRepository } from './base';
import { Pricing, PricingInput, PriceHistory, RepositoryError, ErrorCodes, Env } from '../types';
import { Pricing, PricingInput, PriceHistory, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
export class PricingRepository extends BaseRepository<Pricing> {
protected tableName = 'pricing';
@@ -16,13 +15,11 @@ export class PricingRepository extends BaseRepository<Pricing> {
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
constructor(db: D1Database) {
super(db);
}
@@ -114,20 +111,15 @@ export class PricingRepository extends BaseRepository<Pricing> {
try {
// Build upsert statements for each pricing record
const statements = pricing.map((price) => {
const hourlyKrw = calculateKRWHourly(price.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(price.monthly_price, this.env);
return this.db.prepare(
`INSERT INTO pricing (
instance_type_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
currency, available
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(instance_type_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
currency = excluded.currency,
available = excluded.available`
).bind(
@@ -135,8 +127,6 @@ export class PricingRepository extends BaseRepository<Pricing> {
price.region_id,
price.hourly_price,
price.monthly_price,
hourlyKrw,
monthlyKrw,
price.currency,
price.available
);

View File

@@ -4,9 +4,9 @@
*/
import { BaseRepository } from './base';
import { VpuPricing, VpuPricingInput, RepositoryError, ErrorCodes, Env } from '../types';
import { VpuPricing, VpuPricingInput, RepositoryError, ErrorCodes } from '../types';
import { createLogger } from '../utils/logger';
import { calculateKRWHourly, calculateKRWMonthly } from '../constants';
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
export class VpuPricingRepository extends BaseRepository<VpuPricing> {
protected tableName = 'vpu_pricing';
@@ -16,13 +16,13 @@ export class VpuPricingRepository extends BaseRepository<VpuPricing> {
'region_id',
'hourly_price',
'monthly_price',
'hourly_price_krw',
'monthly_price_krw',
'hourly_price_retail',
'monthly_price_retail',
'currency',
'available',
];
constructor(db: D1Database, private env?: Env) {
constructor(db: D1Database) {
super(db);
}
@@ -85,20 +85,21 @@ export class VpuPricingRepository extends BaseRepository<VpuPricing> {
try {
const statements = pricingData.map((pricing) => {
const hourlyKrw = calculateKRWHourly(pricing.hourly_price, this.env);
const monthlyKrw = calculateKRWMonthly(pricing.monthly_price, this.env);
const hourlyRetail = calculateRetailHourly(pricing.hourly_price);
const monthlyRetail = calculateRetailMonthly(pricing.monthly_price);
return this.db.prepare(
`INSERT INTO vpu_pricing (
vpu_instance_id, region_id, hourly_price, monthly_price,
hourly_price_krw, monthly_price_krw, currency, available
hourly_price_retail, monthly_price_retail,
currency, available
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(vpu_instance_id, region_id)
DO UPDATE SET
hourly_price = excluded.hourly_price,
monthly_price = excluded.monthly_price,
hourly_price_krw = excluded.hourly_price_krw,
monthly_price_krw = excluded.monthly_price_krw,
hourly_price_retail = excluded.hourly_price_retail,
monthly_price_retail = excluded.monthly_price_retail,
currency = excluded.currency,
available = excluded.available,
updated_at = datetime('now')`
@@ -107,20 +108,21 @@ export class VpuPricingRepository extends BaseRepository<VpuPricing> {
pricing.region_id,
pricing.hourly_price,
pricing.monthly_price,
hourlyKrw,
monthlyKrw,
hourlyRetail,
monthlyRetail,
pricing.currency,
pricing.available
);
});
const results = await this.db.batch(statements);
const successCount = results.filter((r) => r.success).length;
const results = await this.executeBatch(statements);
this.logger.info('upsertMany completed', {
total: pricingData.length,
success: successCount,
});
const successCount = results.reduce(
(sum, result) => sum + (result.meta.changes ?? 0),
0
);
this.logger.info('Upserted VPU pricing records', { count: successCount });
return successCount;
} catch (error) {

View File

@@ -64,8 +64,6 @@ interface RawQueryResult {
pricing_region_id: number | null;
hourly_price: number | null;
monthly_price: number | null;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string | null;
pricing_available: number | null;
pricing_created_at: string | null;
@@ -192,8 +190,6 @@ export class QueryService {
pr.region_id as pricing_region_id,
pr.hourly_price,
pr.monthly_price,
pr.hourly_price_krw,
pr.monthly_price_krw,
pr.currency,
pr.available as pricing_available,
pr.created_at as pricing_created_at,
@@ -380,8 +376,6 @@ export class QueryService {
region_id: row.pricing_region_id,
hourly_price: row.hourly_price,
monthly_price: row.monthly_price,
hourly_price_krw: row.hourly_price_krw,
monthly_price_krw: row.monthly_price_krw,
currency: row.currency,
available: row.pricing_available,
created_at: row.pricing_created_at,

View File

@@ -18,6 +18,7 @@ import { VultrConnector } from '../connectors/vultr';
import { AWSConnector } from '../connectors/aws';
import { RepositoryFactory } from '../repositories';
import { createLogger } from '../utils/logger';
import { calculateRetailHourly, calculateRetailMonthly } from '../constants';
import type {
Env,
ProviderSyncResult,
@@ -292,7 +293,23 @@ export class SyncOrchestrator {
}
this.logger.info(`${provider}${stage}`);
// Stage 8: Complete - Update provider status to success
// Stage 8: Sync Anvil Pricing (if applicable)
stage = 'SYNC_ANVIL_PRICING' as SyncStage;
let anvilPricingCount = 0;
try {
anvilPricingCount = await this.syncAnvilPricing(provider);
if (anvilPricingCount > 0) {
this.logger.info(`${provider}${stage}`, { anvil_pricing: anvilPricingCount });
}
} catch (anvilError) {
// Log error but don't fail the entire sync
this.logger.error('Anvil pricing sync failed', {
provider,
error: anvilError instanceof Error ? anvilError.message : String(anvilError)
});
}
// Stage 9: Complete - Update provider status to success
stage = SyncStage.COMPLETE;
await this.repos.providers.updateSyncStatus(provider, 'success');
@@ -832,6 +849,167 @@ export class SyncOrchestrator {
}
}
/**
* Synchronize Anvil pricing based on source provider pricing
*
* Updates anvil_pricing table with retail prices calculated from source pricing
* Formula: retail = cost × 1.21 (10% margin + 10% VAT)
*
* @param provider - Source provider name (linode, vultr, aws)
* @returns Number of anvil_pricing records updated
*/
private async syncAnvilPricing(provider: string): Promise<number> {
this.logger.info('Starting Anvil pricing sync', { provider });
try {
// Step 1: Find all anvil_regions sourced from this provider
const anvilRegionsResult = await this.repos.db
.prepare('SELECT id, source_region_id FROM anvil_regions WHERE source_provider = ?')
.bind(provider)
.all<{ id: number; source_region_id: number }>();
if (!anvilRegionsResult.success || anvilRegionsResult.results.length === 0) {
this.logger.info('No anvil_regions found for provider', { provider });
return 0;
}
const anvilRegions = anvilRegionsResult.results;
this.logger.info('Found anvil_regions', { provider, count: anvilRegions.length });
// Step 2: Find all anvil_pricing records with source_instance_id
const anvilPricingResult = await this.repos.db
.prepare(`
SELECT
ap.id,
ap.anvil_instance_id,
ap.anvil_region_id,
ap.source_instance_id,
ar.source_region_id
FROM anvil_pricing ap
JOIN anvil_regions ar ON ap.anvil_region_id = ar.id
WHERE ar.source_provider = ?
AND ap.source_instance_id IS NOT NULL
`)
.bind(provider)
.all<{
id: number;
anvil_instance_id: number;
anvil_region_id: number;
source_instance_id: number;
source_region_id: number;
}>();
if (!anvilPricingResult.success || anvilPricingResult.results.length === 0) {
this.logger.info('No anvil_pricing records found with source_instance_id', { provider });
return 0;
}
const anvilPricingRecords = anvilPricingResult.results;
this.logger.info('Found anvil_pricing records to update', {
provider,
count: anvilPricingRecords.length
});
// Step 4: Fetch source pricing data in batch
const sourcePricingResult = await this.repos.db
.prepare(`
SELECT
instance_type_id,
region_id,
hourly_price,
monthly_price
FROM pricing
WHERE instance_type_id IN (${anvilPricingRecords.map(() => '?').join(',')})
AND region_id IN (${anvilPricingRecords.map(() => '?').join(',')})
`)
.bind(
...anvilPricingRecords.map(r => r.source_instance_id),
...anvilPricingRecords.map(r => r.source_region_id)
)
.all<{
instance_type_id: number;
region_id: number;
hourly_price: number;
monthly_price: number;
}>();
if (!sourcePricingResult.success || sourcePricingResult.results.length === 0) {
this.logger.warn('No source pricing data found', { provider });
return 0;
}
// Step 5: Build lookup map: `${instance_type_id}_${region_id}` → pricing
const sourcePricingMap = new Map<string, { hourly_price: number; monthly_price: number }>(
sourcePricingResult.results.map(p => [
`${p.instance_type_id}_${p.region_id}`,
{ hourly_price: p.hourly_price, monthly_price: p.monthly_price }
])
);
// Step 6: Prepare update statements
const updateStatements: D1PreparedStatement[] = [];
for (const record of anvilPricingRecords) {
const lookupKey = `${record.source_instance_id}_${record.source_region_id}`;
const sourcePricing = sourcePricingMap.get(lookupKey);
if (!sourcePricing) {
this.logger.warn('Source pricing not found', {
anvil_pricing_id: record.id,
source_instance_id: record.source_instance_id,
source_region_id: record.source_region_id
});
continue;
}
// Calculate retail prices: cost × 1.21
const hourlyPrice = calculateRetailHourly(sourcePricing.hourly_price);
const monthlyPrice = calculateRetailMonthly(sourcePricing.monthly_price);
updateStatements.push(
this.repos.db.prepare(`
UPDATE anvil_pricing
SET
hourly_price = ?,
monthly_price = ?
WHERE id = ?
`).bind(
hourlyPrice,
monthlyPrice,
record.id
)
);
}
if (updateStatements.length === 0) {
this.logger.info('No anvil_pricing records to update', { provider });
return 0;
}
// Step 7: Execute batch update
const results = await this.repos.db.batch(updateStatements);
const successCount = results.reduce(
(sum, result) => sum + (result.meta?.changes ?? 0),
0
);
this.logger.info('Anvil pricing sync completed', {
provider,
updated: successCount,
total: updateStatements.length
});
return successCount;
} catch (error) {
this.logger.error('Anvil pricing sync failed', {
provider,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Create connector for a specific provider
*

View File

@@ -88,8 +88,6 @@ export interface Pricing {
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
@@ -128,8 +126,6 @@ export interface GpuPricing {
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
@@ -157,8 +153,6 @@ export interface G8Pricing {
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
@@ -187,8 +181,6 @@ export interface VpuPricing {
region_id: number;
hourly_price: number;
monthly_price: number;
hourly_price_krw: number | null;
monthly_price_krw: number | null;
currency: string;
available: number; // SQLite boolean (0/1)
created_at: string;
@@ -202,13 +194,13 @@ export interface VpuPricing {
export type ProviderInput = Omit<Provider, 'id' | 'created_at' | 'updated_at'>;
export type RegionInput = Omit<Region, 'id' | 'created_at' | 'updated_at'>;
export type InstanceTypeInput = Omit<InstanceType, 'id' | 'created_at' | 'updated_at'>;
export type PricingInput = Omit<Pricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
export type PricingInput = Omit<Pricing, 'id' | 'created_at' | 'updated_at'>;
export type GpuInstanceInput = Omit<GpuInstance, 'id' | 'created_at' | 'updated_at'>;
export type GpuPricingInput = Omit<GpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
export type GpuPricingInput = Omit<GpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw' | 'hourly_price_retail' | 'monthly_price_retail'>;
export type G8InstanceInput = Omit<G8Instance, 'id' | 'created_at' | 'updated_at'>;
export type G8PricingInput = Omit<G8Pricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
export type G8PricingInput = Omit<G8Pricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw' | 'hourly_price_retail' | 'monthly_price_retail'>;
export type VpuInstanceInput = Omit<VpuInstance, 'id' | 'created_at' | 'updated_at'>;
export type VpuPricingInput = Omit<VpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw'>;
export type VpuPricingInput = Omit<VpuPricing, 'id' | 'created_at' | 'updated_at' | 'hourly_price_krw' | 'monthly_price_krw' | 'hourly_price_retail' | 'monthly_price_retail'>;
// ============================================================
// Error Types
@@ -452,12 +444,6 @@ export interface Env {
LOG_LEVEL?: string;
/** CORS origin for Access-Control-Allow-Origin header (default: '*') */
CORS_ORIGIN?: string;
/** KRW exchange rate (USD to KRW conversion, default: 1450) */
KRW_EXCHANGE_RATE?: string;
/** KRW VAT rate multiplier (default: 1.1 for 10% VAT) */
KRW_VAT_RATE?: string;
/** KRW markup rate multiplier (default: 1.1 for 10% markup) */
KRW_MARKUP_RATE?: string;
}
// ============================================================
@@ -610,3 +596,77 @@ export interface RecommendationResponse {
/** List of recommended instances (sorted by match score) */
recommendations: InstanceRecommendation[];
}
// ============================================================
// Anvil Product Types
// ============================================================
/**
* Anvil Region - Anvil-branded regional datacenters
*/
export interface AnvilRegion {
id: number;
name: string; // "anvil-tyo1", "anvil-sel1"
display_name: string; // "Tokyo 1", "Seoul 1"
country_code: string; // "JP", "KR"
source_provider: string; // "linode", "vultr"
source_region_code: string; // "jp-tyo-3", "nrt"
source_region_id: number; // FK to regions.id
active: number; // SQLite boolean (0/1)
created_at: string;
updated_at: string;
}
/**
* Anvil Instance - Anvil-branded instance specifications
*/
export interface AnvilInstance {
id: number;
name: string; // "anvil-1g-1c"
display_name: string; // "Basic 1GB"
category: 'vm' | 'gpu' | 'g8' | 'vpu';
vcpus: number;
memory_gb: number;
disk_gb: number;
transfer_tb: number | null;
network_gbps: number | null;
gpu_model: string | null; // GPU-specific
gpu_vram_gb: number | null; // GPU-specific
active: number; // SQLite boolean (0/1)
created_at: string;
updated_at: string;
}
/**
* Anvil Pricing - Anvil retail pricing (USD only)
*/
export interface AnvilPricing {
id: number;
anvil_instance_id: number;
anvil_region_id: number;
hourly_price: number; // Retail price (USD)
monthly_price: number; // Retail price (USD)
source_instance_id: number | null; // FK to source tables
created_at: string;
updated_at: string;
}
/**
* Anvil Transfer Pricing - Data transfer pricing per region (USD only)
*/
export interface AnvilTransferPricing {
id: number;
anvil_region_id: number;
price_per_gb: number; // USD/GB
created_at: string;
updated_at: string;
}
// ============================================================
// Anvil Input Types (auto-derived, excluding id and timestamps)
// ============================================================
export type AnvilRegionInput = Omit<AnvilRegion, 'id' | 'created_at' | 'updated_at'>;
export type AnvilInstanceInput = Omit<AnvilInstance, 'id' | 'created_at' | 'updated_at'>;
export type AnvilPricingInput = Omit<AnvilPricing, 'id' | 'created_at' | 'updated_at'>;
export type AnvilTransferPricingInput = Omit<AnvilTransferPricing, 'id' | 'created_at' | 'updated_at'>;