feat(finance): comprehensive payment model, minor units accuracy, and finance invariants

This commit is contained in:
ochenstarik 2026-09-11 15:05:17 +07:00
parent 8910d5dd6a
commit ae35865829
8 changed files with 369 additions and 39 deletions

View file

@ -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: {

View file

@ -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 {

View file

@ -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<Order, 'id' | 'createdAt' | 'updatedAt'>);
};
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('Демонстрационная модель расчёта');
});
});

View file

@ -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<Order>;
confirmOrder(orderId: string, warehouseId?: string): Promise<Order>;
cancelOrder(orderId: string): Promise<Order>;
createPayment(orderId: string, amount: MoneyAmount, method: Payment['method'], autoRecordFinance?: boolean): Promise<Payment>;
createPayment(
orderId: string,
amount: MoneyAmount,
method: Payment['method'],
autoRecordFinance?: boolean,
idempotencyKey?: string
): Promise<Payment>;
createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise<Shipment>;
processReturn(orderId: string, reason: string, items: ReturnItem[], warehouseId?: string, autoRecordFinance?: boolean): Promise<Return>;
}

View file

@ -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<Account>;
transactions: IRepository<CashFlowTransaction>;
getPnLMetrics(period?: string): Promise<PnLMetrics>;
createTransactionFromPayment(paymentId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
createTransactionFromReturn(returnId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
getPnLMetrics(period?: string, startDate?: string, endDate?: string): Promise<PnLMetrics>;
createTransactionFromPayment(
paymentId: string,
orderId: string,
amount: MoneyAmount,
accountId?: string
): Promise<CashFlowTransaction>;
createTransactionFromReturn(
returnId: string,
orderId: string,
amount: MoneyAmount,
accountId?: string
): Promise<CashFlowTransaction>;
}

View file

@ -84,6 +84,8 @@ export function useFinance() {
amount,
accountId,
accountName: accName,
referenceType: 'manual',
referenceId: `manual_${Date.now()}`,
description,
timestamp: new Date().toISOString(),
});

View file

@ -122,13 +122,33 @@ export class MockCommerceService implements ICommerceService {
orderId: string,
amount: MoneyAmount,
method: Payment['method'],
autoRecordFinance = false
autoRecordFinance = false,
idempotencyKey?: string
): Promise<Payment> {
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;
}
}
}

View file

@ -18,12 +18,34 @@ export class MockFinanceService implements IFinanceService {
this.transactions = new BaseMockRepository<CashFlowTransaction>('cash_flow_transaction');
}
async getPnLMetrics(period = 'current_month'): Promise<PnLMetrics> {
async getPnLMetrics(
period = 'current_month',
startDate?: string,
endDate?: string
): Promise<PnLMetrics> {
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<CashFlowTransaction> {
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<CashFlowTransaction> {
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;
}