diff --git a/src/demo/seedState.ts b/src/demo/seedState.ts index e26c4f8..31752e4 100644 --- a/src/demo/seedState.ts +++ b/src/demo/seedState.ts @@ -688,6 +688,7 @@ export function generateSeedFinance(payments: Payment[]): { accountId: 'acc_main_bank', accountName: 'Основной расчетный счет', referenceType: 'manual', + referenceId: `ref_outflow_${idx + 1}`, description: o.name, timestamp, audit: { diff --git a/src/entities/commerce/order.ts b/src/entities/commerce/order.ts index a4178c9..3aab363 100644 --- a/src/entities/commerce/order.ts +++ b/src/entities/commerce/order.ts @@ -3,14 +3,16 @@ import { BaseEntity, MoneyAmount } from '../types'; export type OrderStatus = | 'draft' | 'confirmed' + | 'preparing' | 'processing' + | 'ready_for_shipment' | 'ready' | 'shipped' | 'delivered' | 'completed' | 'cancelled'; -export type PaymentStatus = 'pending' | 'partially_paid' | 'paid' | 'refunded'; +export type PaymentStatus = 'unpaid' | 'pending' | 'partially_paid' | 'paid' | 'overpaid' | 'refunded'; export type FulfillmentStatus = 'unfulfilled' | 'partially_fulfilled' | 'fulfilled' | 'returned'; export interface OrderItem { diff --git a/src/services/__tests__/paymentModelIntegrity.test.ts b/src/services/__tests__/paymentModelIntegrity.test.ts new file mode 100644 index 0000000..615a5cf --- /dev/null +++ b/src/services/__tests__/paymentModelIntegrity.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetDatabase } from '../storage/indexedDb'; +import { getServices } from '../mock'; +import { Order } from '../../entities/commerce/order'; + +describe('Payment Model & Finance Invariants Suite (F03, F07)', () => { + const { commerce, finance } = getServices(); + + beforeEach(async () => { + await resetDatabase(); + }); + + const createTestOrder = async (totalMinor = 100000, currency = 'RUB') => { + return commerce.orders.create({ + orderNumber: `ORD-${Date.now()}`, + customerType: 'contact', + customerId: 'cust_pay', + customerName: 'Платёжный клиент', + status: 'confirmed', + paymentStatus: 'unpaid', + fulfillmentStatus: 'unfulfilled', + items: [ + { + id: 'item_p1', + productId: 'prod_coffee', + sku: 'COFFEE-1', + name: 'Кофе', + quantity: 1, + unitPrice: { minor: totalMinor, currency }, + discountMinor: 0, + totalPrice: { minor: totalMinor, currency }, + }, + ], + subtotal: { minor: totalMinor, currency }, + discountMinor: 0, + taxMinor: 0, + shippingMinor: 0, + total: { minor: totalMinor, currency }, + channel: 'web', + } as Omit); + }; + + it('1. Rejects non-positive payments and currency mismatches', async () => { + const order = await createTestOrder(100000, 'RUB'); + + // 0 minor units rejected + await expect( + commerce.createPayment(order.id, { minor: 0, currency: 'RUB' }, 'card') + ).rejects.toThrow(/больше нуля/); + + // Negative minor units rejected + await expect( + commerce.createPayment(order.id, { minor: -5000, currency: 'RUB' }, 'card') + ).rejects.toThrow(/больше нуля/); + + // Currency mismatch (USD vs RUB) rejected + await expect( + commerce.createPayment(order.id, { minor: 50000, currency: 'USD' }, 'card') + ).rejects.toThrow(/не совпадает/); + }); + + it('2. Computes cumulative payment progression: unpaid -> partially_paid -> paid -> overpaid', async () => { + const order = await createTestOrder(100000, 'RUB'); // Total 1000.00 RUB + expect(order.paymentStatus).toBe('unpaid'); + + // Step 1: Partial payment of 400.00 RUB (40000 minor) + await commerce.createPayment(order.id, { minor: 40000, currency: 'RUB' }, 'card'); + const orderStep1 = await commerce.orders.getById(order.id); + expect(orderStep1?.paymentStatus).toBe('partially_paid'); + + // Step 2: Second payment of 600.00 RUB (cumulative 1000.00 RUB) + await commerce.createPayment(order.id, { minor: 60000, currency: 'RUB' }, 'cash'); + const orderStep2 = await commerce.orders.getById(order.id); + expect(orderStep2?.paymentStatus).toBe('paid'); + + // Step 3: Additional payment of 100.00 RUB (cumulative 1100.00 RUB -> overpaid) + await commerce.createPayment(order.id, { minor: 10000, currency: 'RUB' }, 'online'); + const orderStep3 = await commerce.orders.getById(order.id); + expect(orderStep3?.paymentStatus).toBe('overpaid'); + }); + + it('3. Idempotency by idempotencyKey prevents duplicate payment records', async () => { + const order = await createTestOrder(50000, 'RUB'); + const idempotencyKey = 'unique-idemp-key-12345'; + + // First attempt + const payment1 = await commerce.createPayment( + order.id, + { minor: 50000, currency: 'RUB' }, + 'card', + false, + idempotencyKey + ); + expect(payment1.id).toBeTruthy(); + + // Second attempt with same idempotency key + const payment2 = await commerce.createPayment( + order.id, + { minor: 50000, currency: 'RUB' }, + 'card', + false, + idempotencyKey + ); + + // Must return the exact same payment object without creating a duplicate + expect(payment2.id).toBe(payment1.id); + + const allPayments = await commerce.payments.list({ filter: { orderId: order.id } }); + expect(allPayments.items).toHaveLength(1); + }); + + it('4. Atomic rollback: if finance recording fails, payment is not persisted', async () => { + const order = await createTestOrder(50000, 'RUB'); + + // Create an invalid account setup or pass invalid parameter to trigger finance failure + // If financeService throws during createTransactionFromPayment, commerce payment must rollback + const origCreateTx = finance.createTransactionFromPayment.bind(finance); + finance.createTransactionFromPayment = async () => { + throw new Error('Банковский шлюз недоступен'); + }; + + await expect( + commerce.createPayment(order.id, { minor: 50000, currency: 'RUB' }, 'card', true) + ).rejects.toThrow('Банковский шлюз недоступен'); + + // Verify payment was rolled back and NOT saved + const payments = await commerce.payments.list({ filter: { orderId: order.id } }); + expect(payments.items).toHaveLength(0); + + // Verify order status was NOT changed to paid + const updatedOrder = await commerce.orders.getById(order.id); + expect(updatedOrder?.paymentStatus).toBe('unpaid'); + + // Restore original method + finance.createTransactionFromPayment = origCreateTx; + }); + + it('5. MockFinanceService requires valid referenceType/Id and tags PnL with disclaimer and date filters', async () => { + // 1. Mandatory reference basis + await expect( + finance.createTransactionFromPayment('', 'order_123', { minor: 10000, currency: 'RUB' }) + ).rejects.toThrow(/Обязательное основание/); + + await expect( + finance.createTransactionFromReturn('ret_1', '', { minor: 10000, currency: 'RUB' }) + ).rejects.toThrow(/Обязательное основание/); + + // 2. Add sample transactions in different dates + const accounts = await finance.accounts.list(); + const defaultAcc = + accounts.items[0] || + (await finance.accounts.create({ + name: 'Основной расчётный счёт', + type: 'bank', + balance: { minor: 1000000, currency: 'RUB' }, + currency: 'RUB', + isDefault: true, + status: 'active', + })); + + await finance.transactions.create({ + transactionNumber: 'CFT-DATE-1', + type: 'inflow', + category: 'sales_revenue', + amount: { minor: 150000, currency: 'RUB' }, + accountId: defaultAcc.id, + accountName: defaultAcc.name, + referenceType: 'order_payment', + referenceId: 'pay_date_1', + description: 'Сентябрьский чек', + timestamp: '2026-09-05T12:00:00.000Z', + }); + + await finance.transactions.create({ + transactionNumber: 'CFT-DATE-2', + type: 'inflow', + category: 'sales_revenue', + amount: { minor: 80000, currency: 'RUB' }, + accountId: defaultAcc.id, + accountName: defaultAcc.name, + referenceType: 'order_payment', + referenceId: 'pay_date_2', + description: 'Августовский чек', + timestamp: '2026-08-15T12:00:00.000Z', + }); + + // Filter by date range: only September 2026 + const pnlSept = await finance.getPnLMetrics('2026-09', '2026-09-01', '2026-09-30'); + expect(pnlSept.revenue.minor).toBe(150000); + expect(pnlSept.reportDisclaimer).toBe('Демонстрационная модель расчёта'); + + // Filter all: both August and September + const pnlAll = await finance.getPnLMetrics('all'); + expect(pnlAll.revenue.minor).toBeGreaterThanOrEqual(230000); + expect(pnlAll.reportDisclaimer).toBe('Демонстрационная модель расчёта'); + }); +}); diff --git a/src/services/contracts/commerce.ts b/src/services/contracts/commerce.ts index e13c03c..9232093 100644 --- a/src/services/contracts/commerce.ts +++ b/src/services/contracts/commerce.ts @@ -19,6 +19,7 @@ export interface Payment extends BaseEntity { method: 'card' | 'cash' | 'online' | 'bank_transfer'; status: 'completed' | 'pending' | 'failed' | 'refunded'; transactionId?: string; + idempotencyKey?: string; paidAt: string; recipientAccount?: string; } @@ -70,7 +71,13 @@ export interface ICommerceService { updateOrderStatus(orderId: string, status: Order['status']): Promise; confirmOrder(orderId: string, warehouseId?: string): Promise; cancelOrder(orderId: string): Promise; - createPayment(orderId: string, amount: MoneyAmount, method: Payment['method'], autoRecordFinance?: boolean): Promise; + createPayment( + orderId: string, + amount: MoneyAmount, + method: Payment['method'], + autoRecordFinance?: boolean, + idempotencyKey?: string + ): Promise; createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise; processReturn(orderId: string, reason: string, items: ReturnItem[], warehouseId?: string, autoRecordFinance?: boolean): Promise; } diff --git a/src/services/contracts/finance.ts b/src/services/contracts/finance.ts index 5548cdf..cea799c 100644 --- a/src/services/contracts/finance.ts +++ b/src/services/contracts/finance.ts @@ -29,8 +29,8 @@ export interface CashFlowTransaction extends BaseEntity { amount: MoneyAmount; accountId: string; accountName: string; - referenceType?: 'order_payment' | 'order_return' | 'manual'; - referenceId?: string; + referenceType: 'order_payment' | 'order_return' | 'manual'; + referenceId: string; description: string; timestamp: string; } @@ -43,12 +43,23 @@ export interface PnLMetrics { operatingExpenses: MoneyAmount; netProfit: MoneyAmount; operatingMarginPercent: number; + reportDisclaimer: 'Демонстрационная модель расчёта'; } export interface IFinanceService { accounts: IRepository; transactions: IRepository; - getPnLMetrics(period?: string): Promise; - createTransactionFromPayment(paymentId: string, orderId: string, amount: MoneyAmount): Promise; - createTransactionFromReturn(returnId: string, orderId: string, amount: MoneyAmount): Promise; + getPnLMetrics(period?: string, startDate?: string, endDate?: string): Promise; + createTransactionFromPayment( + paymentId: string, + orderId: string, + amount: MoneyAmount, + accountId?: string + ): Promise; + createTransactionFromReturn( + returnId: string, + orderId: string, + amount: MoneyAmount, + accountId?: string + ): Promise; } diff --git a/src/services/hooks/useFinance.ts b/src/services/hooks/useFinance.ts index 0fe3e63..edffe33 100644 --- a/src/services/hooks/useFinance.ts +++ b/src/services/hooks/useFinance.ts @@ -84,6 +84,8 @@ export function useFinance() { amount, accountId, accountName: accName, + referenceType: 'manual', + referenceId: `manual_${Date.now()}`, description, timestamp: new Date().toISOString(), }); diff --git a/src/services/mock/commerceService.ts b/src/services/mock/commerceService.ts index bff6164..903059e 100644 --- a/src/services/mock/commerceService.ts +++ b/src/services/mock/commerceService.ts @@ -122,13 +122,33 @@ export class MockCommerceService implements ICommerceService { orderId: string, amount: MoneyAmount, method: Payment['method'], - autoRecordFinance = false + autoRecordFinance = false, + idempotencyKey?: string ): Promise { const order = await this.orders.getById(orderId); if (!order) { throw new Error(`Заказ с ID ${orderId} не найден.`); } + // 1. Validation: amount.minor > 0 and currency match + if (amount.minor <= 0) { + throw new Error(`Сумма платежа должна быть больше нуля (получено: ${amount.minor})`); + } + if (amount.currency !== order.total.currency) { + throw new Error( + `Валюта платежа (${amount.currency}) не совпадает с валютой заказа (${order.total.currency})` + ); + } + + // 2. Idempotency check by idempotencyKey + if (idempotencyKey) { + const existingPayments = await this.payments.list({ filter: { orderId } }); + const found = existingPayments.items.find((p) => p.idempotencyKey === idempotencyKey); + if (found) { + return found; + } + } + const now = new Date().toISOString(); const payment = await this.payments.create({ orderId, @@ -136,18 +156,49 @@ export class MockCommerceService implements ICommerceService { amount, method, status: 'completed', + idempotencyKey, paidAt: now, transactionId: `TXN-${ulid().slice(0, 10)}`, }); - // Update order payment status - await this.orders.update(orderId, { paymentStatus: 'paid' }); + // 3. Compute cumulative paid amount to update paymentStatus + const orderPayments = await this.payments.list({ filter: { orderId, status: 'completed' } }); + const cumulativePaidMinor = orderPayments.items.reduce((sum, p) => sum + p.amount.minor, 0); - // Automatically record finance transaction if requested + let nextPaymentStatus: Order['paymentStatus']; + if (cumulativePaidMinor <= 0) { + nextPaymentStatus = 'unpaid'; + } else if (cumulativePaidMinor < order.total.minor) { + nextPaymentStatus = 'partially_paid'; + } else if (cumulativePaidMinor === order.total.minor) { + nextPaymentStatus = 'paid'; + } else { + nextPaymentStatus = 'overpaid'; + } + + await this.orders.update(orderId, { paymentStatus: nextPaymentStatus }); + + // 4. Atomic finance recording: if finance fails, rollback payment creation if (autoRecordFinance) { const finance = getServices().finance; if (finance) { - await finance.createTransactionFromPayment(payment.id, orderId, amount); + try { + await finance.createTransactionFromPayment(payment.id, orderId, amount); + } catch (financeErr) { + await this.payments.delete(payment.id); + const remainingPayments = await this.payments.list({ filter: { orderId, status: 'completed' } }); + const remainingSum = remainingPayments.items.reduce((sum, p) => sum + p.amount.minor, 0); + const restoredStatus: Order['paymentStatus'] = + remainingSum <= 0 + ? 'unpaid' + : remainingSum < order.total.minor + ? 'partially_paid' + : remainingSum === order.total.minor + ? 'paid' + : 'overpaid'; + await this.orders.update(orderId, { paymentStatus: restoredStatus }); + throw financeErr; + } } } diff --git a/src/services/mock/financeService.ts b/src/services/mock/financeService.ts index a763de7..470ebe7 100644 --- a/src/services/mock/financeService.ts +++ b/src/services/mock/financeService.ts @@ -18,12 +18,34 @@ export class MockFinanceService implements IFinanceService { this.transactions = new BaseMockRepository('cash_flow_transaction'); } - async getPnLMetrics(period = 'current_month'): Promise { + async getPnLMetrics( + period = 'current_month', + startDate?: string, + endDate?: string + ): Promise { const txs = await this.transactions.list({ pageSize: 500 }); + let filteredTxs = txs.items; + + if (startDate || endDate) { + filteredTxs = filteredTxs.filter((tx) => { + const txDate = tx.timestamp.slice(0, 10); + if (startDate && txDate < startDate.slice(0, 10)) return false; + if (endDate && txDate > endDate.slice(0, 10)) return false; + return true; + }); + } else if (period && period !== 'all') { + if (period === 'current_month') { + const currentMonth = new Date().toISOString().slice(0, 7); + filteredTxs = filteredTxs.filter((tx) => tx.timestamp.startsWith(currentMonth)); + } else if (/^\d{4}-\d{2}$/.test(period)) { + filteredTxs = filteredTxs.filter((tx) => tx.timestamp.startsWith(period)); + } + } + let revenueMinor = 0; let expenseMinor = 0; - for (const tx of txs.items) { + for (const tx of filteredTxs) { if (tx.type === 'inflow') { revenueMinor += tx.amount.minor; } else { @@ -31,7 +53,7 @@ export class MockFinanceService implements IFinanceService { } } - const cogsMinor = Math.round(revenueMinor * 0.42); // 42% cost of goods sold + const cogsMinor = Math.round(revenueMinor * 0.42); // 42% cost of goods sold model const grossProfitMinor = revenueMinor - cogsMinor; const operatingExpensesMinor = expenseMinor; const netProfitMinor = grossProfitMinor - operatingExpensesMinor; @@ -46,24 +68,45 @@ export class MockFinanceService implements IFinanceService { operatingExpenses: { minor: operatingExpensesMinor, currency: 'RUB' }, netProfit: { minor: netProfitMinor, currency: 'RUB' }, operatingMarginPercent, + reportDisclaimer: 'Демонстрационная модель расчёта', }; } async createTransactionFromPayment( paymentId: string, orderId: string, - amount: MoneyAmount + amount: MoneyAmount, + accountId?: string ): Promise { + if (!paymentId || !orderId) { + throw new Error('Обязательное основание операции: referenceType="order_payment", paymentId и orderId'); + } + if (amount.minor <= 0) { + throw new Error(`Сумма входящей проводки должна быть больше 0 (получено: ${amount.minor})`); + } + const accounts = await this.accounts.list(); - const defaultAcc = accounts.items.find((a) => a.isDefault) || accounts.items[0]; + const targetAcc = accountId + ? accounts.items.find((a) => a.id === accountId) + : accounts.items.find((a) => a.isDefault) || accounts.items[0]; + + if (!targetAcc) { + throw new Error('Расчётный счёт для проведения оплаты не найден.'); + } + + if (targetAcc.currency !== amount.currency) { + throw new Error( + `Несовпадение валюты счёта (${targetAcc.currency}) и валюты платежа (${amount.currency})` + ); + } const tx = await this.transactions.create({ transactionNumber: `CFT-${ulid().slice(0, 8)}`, type: 'inflow', category: 'sales_revenue', amount, - accountId: defaultAcc?.id || 'acc_default', - accountName: defaultAcc?.name || 'Основной расчетный счет', + accountId: targetAcc.id, + accountName: targetAcc.name, referenceType: 'order_payment', referenceId: paymentId, description: `Оплата по заказу (${orderId.slice(0, 8)}...)`, @@ -71,14 +114,12 @@ export class MockFinanceService implements IFinanceService { }); // Update account balance - if (defaultAcc) { - await this.accounts.update(defaultAcc.id, { - balance: { - minor: defaultAcc.balance.minor + amount.minor, - currency: defaultAcc.balance.currency, - }, - }); - } + await this.accounts.update(targetAcc.id, { + balance: { + minor: targetAcc.balance.minor + amount.minor, + currency: targetAcc.balance.currency, + }, + }); return tx; } @@ -86,18 +127,38 @@ export class MockFinanceService implements IFinanceService { async createTransactionFromReturn( returnId: string, orderId: string, - amount: MoneyAmount + amount: MoneyAmount, + accountId?: string ): Promise { + if (!returnId || !orderId) { + throw new Error('Обязательное основание операции: referenceType="order_return", returnId и orderId'); + } + if (amount.minor <= 0) { + throw new Error(`Сумма исходящей проводки возврата должна быть больше 0 (получено: ${amount.minor})`); + } + const accounts = await this.accounts.list(); - const defaultAcc = accounts.items.find((a) => a.isDefault) || accounts.items[0]; + const targetAcc = accountId + ? accounts.items.find((a) => a.id === accountId) + : accounts.items.find((a) => a.isDefault) || accounts.items[0]; + + if (!targetAcc) { + throw new Error('Расчётный счёт для возврата средств не найден.'); + } + + if (targetAcc.currency !== amount.currency) { + throw new Error( + `Несовпадение валюты счёта (${targetAcc.currency}) и валюты возврата (${amount.currency})` + ); + } const tx = await this.transactions.create({ transactionNumber: `CFT-${ulid().slice(0, 8)}`, type: 'outflow', category: 'refund', amount, - accountId: defaultAcc?.id || 'acc_default', - accountName: defaultAcc?.name || 'Основной расчетный счет', + accountId: targetAcc.id, + accountName: targetAcc.name, referenceType: 'order_return', referenceId: returnId, description: `Возврат средств по заказу (${orderId.slice(0, 8)}...)`, @@ -105,14 +166,12 @@ export class MockFinanceService implements IFinanceService { }); // Update account balance - if (defaultAcc) { - await this.accounts.update(defaultAcc.id, { - balance: { - minor: Math.max(0, defaultAcc.balance.minor - amount.minor), - currency: defaultAcc.balance.currency, - }, - }); - } + await this.accounts.update(targetAcc.id, { + balance: { + minor: targetAcc.balance.minor - amount.minor, + currency: targetAcc.balance.currency, + }, + }); return tx; }