feat(finance): comprehensive payment model, minor units accuracy, and finance invariants
This commit is contained in:
parent
8910d5dd6a
commit
ae35865829
8 changed files with 369 additions and 39 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
197
src/services/__tests__/paymentModelIntegrity.test.ts
Normal file
197
src/services/__tests__/paymentModelIntegrity.test.ts
Normal 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('Демонстрационная модель расчёта');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export function useFinance() {
|
|||
amount,
|
||||
accountId,
|
||||
accountName: accName,
|
||||
referenceType: 'manual',
|
||||
referenceId: `manual_${Date.now()}`,
|
||||
description,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
await this.accounts.update(targetAcc.id, {
|
||||
balance: {
|
||||
minor: defaultAcc.balance.minor + amount.minor,
|
||||
currency: defaultAcc.balance.currency,
|
||||
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, {
|
||||
await this.accounts.update(targetAcc.id, {
|
||||
balance: {
|
||||
minor: Math.max(0, defaultAcc.balance.minor - amount.minor),
|
||||
currency: defaultAcc.balance.currency,
|
||||
minor: targetAcc.balance.minor - amount.minor,
|
||||
currency: targetAcc.balance.currency,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue