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',
|
accountId: 'acc_main_bank',
|
||||||
accountName: 'Основной расчетный счет',
|
accountName: 'Основной расчетный счет',
|
||||||
referenceType: 'manual',
|
referenceType: 'manual',
|
||||||
|
referenceId: `ref_outflow_${idx + 1}`,
|
||||||
description: o.name,
|
description: o.name,
|
||||||
timestamp,
|
timestamp,
|
||||||
audit: {
|
audit: {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@ import { BaseEntity, MoneyAmount } from '../types';
|
||||||
export type OrderStatus =
|
export type OrderStatus =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
| 'confirmed'
|
| 'confirmed'
|
||||||
|
| 'preparing'
|
||||||
| 'processing'
|
| 'processing'
|
||||||
|
| 'ready_for_shipment'
|
||||||
| 'ready'
|
| 'ready'
|
||||||
| 'shipped'
|
| 'shipped'
|
||||||
| 'delivered'
|
| 'delivered'
|
||||||
| 'completed'
|
| 'completed'
|
||||||
| 'cancelled';
|
| '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 type FulfillmentStatus = 'unfulfilled' | 'partially_fulfilled' | 'fulfilled' | 'returned';
|
||||||
|
|
||||||
export interface OrderItem {
|
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';
|
method: 'card' | 'cash' | 'online' | 'bank_transfer';
|
||||||
status: 'completed' | 'pending' | 'failed' | 'refunded';
|
status: 'completed' | 'pending' | 'failed' | 'refunded';
|
||||||
transactionId?: string;
|
transactionId?: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
paidAt: string;
|
paidAt: string;
|
||||||
recipientAccount?: string;
|
recipientAccount?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +71,13 @@ export interface ICommerceService {
|
||||||
updateOrderStatus(orderId: string, status: Order['status']): Promise<Order>;
|
updateOrderStatus(orderId: string, status: Order['status']): Promise<Order>;
|
||||||
confirmOrder(orderId: string, warehouseId?: string): Promise<Order>;
|
confirmOrder(orderId: string, warehouseId?: string): Promise<Order>;
|
||||||
cancelOrder(orderId: 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>;
|
createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise<Shipment>;
|
||||||
processReturn(orderId: string, reason: string, items: ReturnItem[], warehouseId?: string, autoRecordFinance?: boolean): Promise<Return>;
|
processReturn(orderId: string, reason: string, items: ReturnItem[], warehouseId?: string, autoRecordFinance?: boolean): Promise<Return>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ export interface CashFlowTransaction extends BaseEntity {
|
||||||
amount: MoneyAmount;
|
amount: MoneyAmount;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
referenceType?: 'order_payment' | 'order_return' | 'manual';
|
referenceType: 'order_payment' | 'order_return' | 'manual';
|
||||||
referenceId?: string;
|
referenceId: string;
|
||||||
description: string;
|
description: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
@ -43,12 +43,23 @@ export interface PnLMetrics {
|
||||||
operatingExpenses: MoneyAmount;
|
operatingExpenses: MoneyAmount;
|
||||||
netProfit: MoneyAmount;
|
netProfit: MoneyAmount;
|
||||||
operatingMarginPercent: number;
|
operatingMarginPercent: number;
|
||||||
|
reportDisclaimer: 'Демонстрационная модель расчёта';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFinanceService {
|
export interface IFinanceService {
|
||||||
accounts: IRepository<Account>;
|
accounts: IRepository<Account>;
|
||||||
transactions: IRepository<CashFlowTransaction>;
|
transactions: IRepository<CashFlowTransaction>;
|
||||||
getPnLMetrics(period?: string): Promise<PnLMetrics>;
|
getPnLMetrics(period?: string, startDate?: string, endDate?: string): Promise<PnLMetrics>;
|
||||||
createTransactionFromPayment(paymentId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
|
createTransactionFromPayment(
|
||||||
createTransactionFromReturn(returnId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
|
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,
|
amount,
|
||||||
accountId,
|
accountId,
|
||||||
accountName: accName,
|
accountName: accName,
|
||||||
|
referenceType: 'manual',
|
||||||
|
referenceId: `manual_${Date.now()}`,
|
||||||
description,
|
description,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -122,13 +122,33 @@ export class MockCommerceService implements ICommerceService {
|
||||||
orderId: string,
|
orderId: string,
|
||||||
amount: MoneyAmount,
|
amount: MoneyAmount,
|
||||||
method: Payment['method'],
|
method: Payment['method'],
|
||||||
autoRecordFinance = false
|
autoRecordFinance = false,
|
||||||
|
idempotencyKey?: string
|
||||||
): Promise<Payment> {
|
): Promise<Payment> {
|
||||||
const order = await this.orders.getById(orderId);
|
const order = await this.orders.getById(orderId);
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
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 now = new Date().toISOString();
|
||||||
const payment = await this.payments.create({
|
const payment = await this.payments.create({
|
||||||
orderId,
|
orderId,
|
||||||
|
|
@ -136,18 +156,49 @@ export class MockCommerceService implements ICommerceService {
|
||||||
amount,
|
amount,
|
||||||
method,
|
method,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
|
idempotencyKey,
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
transactionId: `TXN-${ulid().slice(0, 10)}`,
|
transactionId: `TXN-${ulid().slice(0, 10)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update order payment status
|
// 3. Compute cumulative paid amount to update paymentStatus
|
||||||
await this.orders.update(orderId, { paymentStatus: 'paid' });
|
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) {
|
if (autoRecordFinance) {
|
||||||
const finance = getServices().finance;
|
const finance = getServices().finance;
|
||||||
if (finance) {
|
if (finance) {
|
||||||
|
try {
|
||||||
await finance.createTransactionFromPayment(payment.id, orderId, amount);
|
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');
|
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 });
|
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 revenueMinor = 0;
|
||||||
let expenseMinor = 0;
|
let expenseMinor = 0;
|
||||||
|
|
||||||
for (const tx of txs.items) {
|
for (const tx of filteredTxs) {
|
||||||
if (tx.type === 'inflow') {
|
if (tx.type === 'inflow') {
|
||||||
revenueMinor += tx.amount.minor;
|
revenueMinor += tx.amount.minor;
|
||||||
} else {
|
} 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 grossProfitMinor = revenueMinor - cogsMinor;
|
||||||
const operatingExpensesMinor = expenseMinor;
|
const operatingExpensesMinor = expenseMinor;
|
||||||
const netProfitMinor = grossProfitMinor - operatingExpensesMinor;
|
const netProfitMinor = grossProfitMinor - operatingExpensesMinor;
|
||||||
|
|
@ -46,24 +68,45 @@ export class MockFinanceService implements IFinanceService {
|
||||||
operatingExpenses: { minor: operatingExpensesMinor, currency: 'RUB' },
|
operatingExpenses: { minor: operatingExpensesMinor, currency: 'RUB' },
|
||||||
netProfit: { minor: netProfitMinor, currency: 'RUB' },
|
netProfit: { minor: netProfitMinor, currency: 'RUB' },
|
||||||
operatingMarginPercent,
|
operatingMarginPercent,
|
||||||
|
reportDisclaimer: 'Демонстрационная модель расчёта',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTransactionFromPayment(
|
async createTransactionFromPayment(
|
||||||
paymentId: string,
|
paymentId: string,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
amount: MoneyAmount
|
amount: MoneyAmount,
|
||||||
|
accountId?: string
|
||||||
): Promise<CashFlowTransaction> {
|
): 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 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({
|
const tx = await this.transactions.create({
|
||||||
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
||||||
type: 'inflow',
|
type: 'inflow',
|
||||||
category: 'sales_revenue',
|
category: 'sales_revenue',
|
||||||
amount,
|
amount,
|
||||||
accountId: defaultAcc?.id || 'acc_default',
|
accountId: targetAcc.id,
|
||||||
accountName: defaultAcc?.name || 'Основной расчетный счет',
|
accountName: targetAcc.name,
|
||||||
referenceType: 'order_payment',
|
referenceType: 'order_payment',
|
||||||
referenceId: paymentId,
|
referenceId: paymentId,
|
||||||
description: `Оплата по заказу (${orderId.slice(0, 8)}...)`,
|
description: `Оплата по заказу (${orderId.slice(0, 8)}...)`,
|
||||||
|
|
@ -71,14 +114,12 @@ export class MockFinanceService implements IFinanceService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update account balance
|
// Update account balance
|
||||||
if (defaultAcc) {
|
await this.accounts.update(targetAcc.id, {
|
||||||
await this.accounts.update(defaultAcc.id, {
|
|
||||||
balance: {
|
balance: {
|
||||||
minor: defaultAcc.balance.minor + amount.minor,
|
minor: targetAcc.balance.minor + amount.minor,
|
||||||
currency: defaultAcc.balance.currency,
|
currency: targetAcc.balance.currency,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return tx;
|
return tx;
|
||||||
}
|
}
|
||||||
|
|
@ -86,18 +127,38 @@ export class MockFinanceService implements IFinanceService {
|
||||||
async createTransactionFromReturn(
|
async createTransactionFromReturn(
|
||||||
returnId: string,
|
returnId: string,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
amount: MoneyAmount
|
amount: MoneyAmount,
|
||||||
|
accountId?: string
|
||||||
): Promise<CashFlowTransaction> {
|
): 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 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({
|
const tx = await this.transactions.create({
|
||||||
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
||||||
type: 'outflow',
|
type: 'outflow',
|
||||||
category: 'refund',
|
category: 'refund',
|
||||||
amount,
|
amount,
|
||||||
accountId: defaultAcc?.id || 'acc_default',
|
accountId: targetAcc.id,
|
||||||
accountName: defaultAcc?.name || 'Основной расчетный счет',
|
accountName: targetAcc.name,
|
||||||
referenceType: 'order_return',
|
referenceType: 'order_return',
|
||||||
referenceId: returnId,
|
referenceId: returnId,
|
||||||
description: `Возврат средств по заказу (${orderId.slice(0, 8)}...)`,
|
description: `Возврат средств по заказу (${orderId.slice(0, 8)}...)`,
|
||||||
|
|
@ -105,14 +166,12 @@ export class MockFinanceService implements IFinanceService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update account balance
|
// Update account balance
|
||||||
if (defaultAcc) {
|
await this.accounts.update(targetAcc.id, {
|
||||||
await this.accounts.update(defaultAcc.id, {
|
|
||||||
balance: {
|
balance: {
|
||||||
minor: Math.max(0, defaultAcc.balance.minor - amount.minor),
|
minor: targetAcc.balance.minor - amount.minor,
|
||||||
currency: defaultAcc.balance.currency,
|
currency: targetAcc.balance.currency,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return tx;
|
return tx;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue