import db from '../config/database'; import { logger } from '../utils/logger'; import { B2BCustomer, TieredPrice, TieredPriceResult, CreditStatus, BatchOrderItem, BatchOrder } from '../models/B2B'; import { PricingService } from './PricingService'; export interface TieredPricingInput { productId: string; quantity: number; customerId: string; tenantId: string; shopId?: string; taskId?: string; traceId: string; businessType: 'TOC' | 'TOB'; } export interface TieredPricingOutput { unitPrice: number; totalAmount: number; discountPercent: number; appliedTier: { minQuantity: number; maxQuantity?: number; }; profitMargin: number; isApproved: boolean; rejectionReason?: string; } export class B2BTradeService { private static readonly PROFIT_MARGIN_THRESHOLD = 0.15; private static readonly DEFAULT_TIERED_PRICES: TieredPrice[] = [ { product_id: '', tenant_id: '', min_quantity: 1, max_quantity: 9, price: 0, discount_percent: 0 }, { product_id: '', tenant_id: '', min_quantity: 10, max_quantity: 49, price: 0, discount_percent: 5 }, { product_id: '', tenant_id: '', min_quantity: 50, max_quantity: 99, price: 0, discount_percent: 10 }, { product_id: '', tenant_id: '', min_quantity: 100, max_quantity: 499, price: 0, discount_percent: 15 }, { product_id: '', tenant_id: '', min_quantity: 500, max_quantity: undefined, price: 0, discount_percent: 20 }, ]; static async calculateTieredPrice( input: TieredPricingInput ): Promise { const { productId, quantity, customerId, tenantId, shopId, taskId, traceId, businessType } = input; logger.info(`[B2BTradeService] Calculating tiered price - productId: ${productId}, quantity: ${quantity}, customerId: ${customerId}, tenantId: ${tenantId}, traceId: ${traceId}`); try { const basePrice = await this.getProductBasePrice(productId, tenantId, traceId); const appliedTier = this.findApplicableTier(quantity); const discountMultiplier = 1 - (appliedTier.discount_percent / 100); const unitPrice = Number((basePrice * discountMultiplier).toFixed(2)); const totalAmount = Number((unitPrice * quantity).toFixed(2)); const costPrice = basePrice; const profitMargin = Number(((unitPrice - costPrice) / unitPrice).toFixed(4)); const isApproved = profitMargin >= this.PROFIT_MARGIN_THRESHOLD; let rejectionReason: string | undefined; if (!isApproved) { rejectionReason = `Profit margin ${(profitMargin * 100).toFixed(2)}% is below the minimum threshold of ${(this.PROFIT_MARGIN_THRESHOLD * 100).toFixed(0)}% for B2B transactions`; logger.warn(`[B2BTradeService] Tiered price rejected due to low profit margin - productId: ${productId}, profitMargin: ${profitMargin}, threshold: ${this.PROFIT_MARGIN_THRESHOLD}`); } const result: TieredPricingOutput = { unitPrice, totalAmount, discountPercent: appliedTier.discount_percent, appliedTier: { minQuantity: appliedTier.min_quantity, maxQuantity: appliedTier.max_quantity, }, profitMargin, isApproved, rejectionReason, }; logger.info(`[B2BTradeService] Tiered price calculated - unitPrice: ${unitPrice}, totalAmount: ${totalAmount}, profitMargin: ${profitMargin}, isApproved: ${isApproved}`); return result; } catch (error: any) { logger.error(`[B2BTradeService] Tiered price calculation failed - error: ${error.message}`); throw error; } } static async createBatchOrder( customerId: string, items: BatchOrderItem[], options: { tenantId: string; shopId?: string; taskId?: string; traceId: string; businessType: 'TOC' | 'TOB'; } ): Promise { const { tenantId, shopId, taskId, traceId, businessType } = options; logger.info(`[B2BTradeService] Creating batch order - customerId: ${customerId}, itemCount: ${items.length}, tenantId: ${tenantId}, traceId: ${traceId}`); try { const creditStatus = await this.checkCreditLimit(customerId, tenantId, traceId); if (!creditStatus.canPlaceOrder) { throw new Error(`Credit limit exceeded: ${creditStatus.reason}`); } let totalAmount = 0; for (const item of items) { const itemTotal = item.unit_price * item.quantity * (1 - (item.discount_percent || 0) / 100); totalAmount += itemTotal; } totalAmount = Number(totalAmount.toFixed(2)); if (totalAmount > creditStatus.creditAvailable) { throw new Error(`Order amount ${totalAmount} exceeds available credit ${creditStatus.creditAvailable}`); } const order: BatchOrder = { tenant_id: tenantId, customer_id: customerId, total_amount: totalAmount, payment_status: 'PENDING', trace_id: traceId, task_id: taskId, business_type: 'TOB', items: items, }; logger.info(`[B2BTradeService] Batch order created - customerId: ${customerId}, totalAmount: ${totalAmount}, tenantId: ${tenantId}`); return order; } catch (error: any) { logger.error(`[B2BTradeService] Batch order creation failed - error: ${error.message}`); throw error; } } static async checkCreditLimit( customerId: string, tenantId: string, traceId: string ): Promise { logger.info(`[B2BTradeService] Checking credit limit - customerId: ${customerId}, tenantId: ${tenantId}, traceId: ${traceId}`); const mockCustomer: B2BCustomer = { id: customerId, tenant_id: tenantId, company_name: 'Sample Company', credit_limit: 50000, credit_used: 15000, payment_terms: 30, tier_level: 'SILVER', status: 'ACTIVE', trace_id: traceId, }; const creditLimit = mockCustomer.credit_limit; const creditUsed = mockCustomer.credit_used; const creditAvailable = creditLimit - creditUsed; const canPlaceOrder = mockCustomer.status === 'ACTIVE' && creditAvailable > 0; const result: CreditStatus = { customerId, creditLimit, creditUsed, creditAvailable, canPlaceOrder, reason: canPlaceOrder ? undefined : 'Customer is not active or has no available credit', }; logger.info(`[B2BTradeService] Credit limit checked - creditLimit: ${creditLimit}, creditUsed: ${creditUsed}, creditAvailable: ${creditAvailable}, canPlaceOrder: ${canPlaceOrder}`); return result; } static async setPaymentTerms( customerId: string, terms: { days: number; autoApprove: boolean; }, options: { tenantId: string; traceId: string; } ): Promise<{ customerId: string; days: number; status: 'ACTIVE' | 'SUSPENDED' | 'EXPIRED'; effectiveFrom: Date }> { const { tenantId, traceId } = options; logger.info(`[B2BTradeService] Setting payment terms - customerId: ${customerId}, days: ${terms.days}, tenantId: ${tenantId}, traceId: ${traceId}`); return { customerId, days: terms.days, status: 'ACTIVE', effectiveFrom: new Date(), }; } private static async getProductBasePrice(productId: string, tenantId: string, traceId: string): Promise { logger.info(`[B2BTradeService] Getting product base price - productId: ${productId}, tenantId: ${tenantId}`); return 100; } private static findApplicableTier(quantity: number): TieredPrice { for (const tier of this.DEFAULT_TIERED_PRICES) { if (quantity >= tier.min_quantity) { if (tier.max_quantity === undefined || quantity <= tier.max_quantity) { return tier; } } } return this.DEFAULT_TIERED_PRICES[this.DEFAULT_TIERED_PRICES.length - 1]; } static async getBatchOrderById(orderId: string, tenantId: string): Promise { logger.info(`[B2BTradeService] Getting batch order by ID - orderId: ${orderId}, tenantId: ${tenantId}`); try { const order = await db('cf_b2b_batch_orders') .where({ id: orderId, tenant_id: tenantId }) .first(); if (!order) { return null; } if (typeof order.items === 'string') { order.items = JSON.parse(order.items); } return order as BatchOrder; } catch (error: any) { logger.error(`[B2BTradeService] Failed to get batch order - orderId: ${orderId}, error: ${error.message}`); throw error; } } static async updatePaymentStatus( orderId: string, status: 'PENDING' | 'PARTIAL' | 'PAID' | 'OVERDUE', options: { tenantId: string; shopId?: string; traceId: string; } ): Promise { const { tenantId, traceId } = options; logger.info(`[B2BTradeService] Updating payment status - orderId: ${orderId}, status: ${status}, tenantId: ${tenantId}, traceId: ${traceId}`); try { await db('cf_b2b_batch_orders') .where({ id: orderId, tenant_id: tenantId }) .update({ payment_status: status, updated_at: new Date(), }); logger.info(`[B2BTradeService] Payment status updated - orderId: ${orderId}, status: ${status}`); } catch (error: any) { logger.error(`[B2BTradeService] Failed to update payment status - orderId: ${orderId}, error: ${error.message}`); throw error; } } static async checkOverdueOrders(tenantId: string): Promise { logger.info(`[B2BTradeService] Checking overdue orders - tenantId: ${tenantId}`); try { const overdueOrders = await db('cf_b2b_batch_orders') .where({ tenant_id: tenantId, payment_status: 'PENDING' }) .where('due_date', '<', new Date()) .select('id'); const overdueIds = overdueOrders.map((order: any) => order.id); if (overdueIds.length > 0) { await db('cf_b2b_batch_orders') .whereIn('id', overdueIds) .update({ payment_status: 'OVERDUE', updated_at: new Date() }); logger.info(`[B2BTradeService] Updated overdue orders - tenantId: ${tenantId}, overdueCount: ${overdueIds.length}`); } return overdueIds; } catch (error: any) { logger.error(`[B2BTradeService] Failed to check overdue orders - tenantId: ${tenantId}, error: ${error.message}`); throw error; } } static async createCustomer( customerData: Omit, options: { tenantId: string; shopId?: string; traceId: string; } ): Promise { const { tenantId, shopId, traceId } = options; logger.info(`[B2BTradeService] Creating B2B customer - companyName: ${customerData.company_name}, tenantId: ${tenantId}, traceId: ${traceId}`); try { const customerId = `B2B-CUST-${Date.now()}-${Math.floor(Math.random() * 10000)}`; const customer = { id: customerId, ...customerData, tenant_id: tenantId, trace_id: traceId, business_type: 'TOB', created_at: new Date(), updated_at: new Date(), }; await db('cf_b2b_customers').insert(customer); logger.info(`[B2BTradeService] B2B customer created - customerId: ${customerId}, tenantId: ${tenantId}`); return customerId; } catch (error: any) { logger.error(`[B2BTradeService] Failed to create B2B customer - tenantId: ${tenantId}, error: ${error.message}`); throw error; } } static async createTieredPrice( priceData: Omit, options: { tenantId: string; shopId?: string; traceId: string; } ): Promise { const { tenantId, shopId, traceId } = options; logger.info(`[B2BTradeService] Creating tiered price - productId: ${priceData.product_id}, tenantId: ${tenantId}, traceId: ${traceId}`); try { const priceId = `TIER-${Date.now()}-${Math.floor(Math.random() * 10000)}`; const tieredPrice = { id: priceId, ...priceData, tenant_id: tenantId, trace_id: traceId, created_at: new Date(), }; await db('cf_b2b_tiered_prices').insert(tieredPrice); logger.info(`[B2BTradeService] Tiered price created - priceId: ${priceId}, productId: ${priceData.product_id}`); return priceId; } catch (error: any) { logger.error(`[B2BTradeService] Failed to create tiered price - tenantId: ${tenantId}, error: ${error.message}`); throw error; } } static async initTables(): Promise { logger.info('[B2BTradeService] Initializing B2B tables'); try { if (!(await db.schema.hasTable('cf_b2b_customers'))) { await db.schema.createTable('cf_b2b_customers', (table) => { table.string('id').primary(); table.string('tenant_id').notNullable().index(); table.string('company_name').notNullable(); table.string('contact_name'); table.string('contact_email'); table.string('contact_phone'); table.decimal('credit_limit', 10, 2).notNullable().defaultTo(0); table.decimal('credit_used', 10, 2).notNullable().defaultTo(0); table.integer('payment_terms').notNullable().defaultTo(30); table.enum('tier_level', ['BRONZE', 'SILVER', 'GOLD', 'PLATINUM']).notNullable().defaultTo('BRONZE'); table.enum('status', ['ACTIVE', 'SUSPENDED', 'INACTIVE']).notNullable().defaultTo('ACTIVE'); table.string('trace_id'); table.string('business_type').defaultTo('TOB'); table.timestamp('created_at').defaultTo(db.fn.now()); table.timestamp('updated_at').defaultTo(db.fn.now()); }); logger.info('[B2BTradeService] Table cf_b2b_customers created'); } if (!(await db.schema.hasTable('cf_b2b_tiered_prices'))) { await db.schema.createTable('cf_b2b_tiered_prices', (table) => { table.string('id').primary(); table.string('product_id').notNullable().index(); table.string('tenant_id').notNullable().index(); table.integer('min_quantity').notNullable(); table.integer('max_quantity'); table.decimal('price', 10, 2).notNullable(); table.decimal('discount_percent', 5, 2).notNullable().defaultTo(0); table.string('trace_id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.index(['product_id', 'tenant_id', 'min_quantity']); }); logger.info('[B2BTradeService] Table cf_b2b_tiered_prices created'); } if (!(await db.schema.hasTable('cf_b2b_batch_orders'))) { await db.schema.createTable('cf_b2b_batch_orders', (table) => { table.string('id').primary(); table.string('tenant_id').notNullable().index(); table.string('customer_id').notNullable().index(); table.decimal('total_amount', 10, 2).notNullable(); table.enum('payment_status', ['PENDING', 'PARTIAL', 'PAID', 'OVERDUE']).notNullable().defaultTo('PENDING'); table.date('due_date'); table.json('items'); table.string('trace_id'); table.string('task_id'); table.string('business_type').defaultTo('TOB'); table.timestamp('created_at').defaultTo(db.fn.now()); table.timestamp('updated_at').defaultTo(db.fn.now()); }); logger.info('[B2BTradeService] Table cf_b2b_batch_orders created'); } logger.info('[B2BTradeService] B2B tables initialized successfully'); } catch (error: any) { logger.error(`[B2BTradeService] Failed to initialize B2B tables - error: ${error.message}`); throw error; } } }