[598][@ant] feat: замена непоставленного без доплаты — докладка конвертирует только дефицit сверх членских средств; при замене высвобожденные отменой средства уже в членском → convert не зовётся, заказ из остатка из членских (помощники economy units↔asset/lineUnits + чтение w.mkt.member)

This commit is contained in:
ant
2026-06-15 17:26:56 +00:00
parent aaf5a7a854
commit 0d0609ddb9
2 changed files with 63 additions and 7 deletions
@@ -154,6 +154,28 @@ export class MarketplaceEconomyService {
return BigInt(int || '0') * BigInt(10) ** BigInt(decimals) + BigInt(fracPadded || '0');
}
/**
* Asset-строка («X.XXXX RUB» или «X.XXXX») → минимальные единицы валюты.
* Символ отбрасывается; целочисленно, без float-погрешности.
*/
assetToUnits(value: string): bigint {
const numeric = String(value).trim().split(/\s+/)[0] ?? '0';
return this.toUnits(numeric, this.assetConfig.decimals);
}
/** Минимальные единицы валюты → asset-строка «X.XXXX RUB». */
unitsToAsset(units: bigint): string {
const decimals = this.assetConfig.decimals;
const padded = units.toString().padStart(decimals + 1, '0');
return `${padded.slice(0, padded.length - decimals)}.${padded.slice(-decimals)} ${this.assetConfig.symbol}`;
}
/** Сумма строки заказа (тело + членский взнос) в минимальных единицах валюты. */
lineUnits(pricePerUnit: string, quantity: number, contractPercent: number): bigint {
const totalUnits = this.toUnits(pricePerUnit, this.assetConfig.decimals) * BigInt(quantity);
return totalUnits + this.membershipFeeUnits(totalUnits, contractPercent);
}
async setMembershipFee(coopname: string, feePercentHuman: number): Promise<number> {
const membership_fee_percent = this.toContractPercent(
feePercentHuman,
@@ -11,6 +11,10 @@ import { Cooperative, type MarketContract } from 'cooptypes';
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
import {
USER_WALLET_REPOSITORY,
type UserWalletRepository,
} from '~/domain/wallet/repositories/user-wallet.repository';
import type { MarketplaceCheckoutSignedLineInputDTO } from '../dto/marketplace-checkout.dto';
import { computeStockOrderHash } from '../shared/order-hash.util';
import {
@@ -78,6 +82,8 @@ export class MarketplaceStockProposalService {
private readonly stockService: MarketplaceStockService,
@Inject(MARKETPLACE_ECONOMY_SERVICE)
private readonly economyService: MarketplaceEconomyService,
@Inject(USER_WALLET_REPOSITORY)
private readonly userWalletRepo: UserWalletRepository,
private readonly documentDomainService: DocumentDomainService,
private readonly eventBus: EventEmitter2,
private readonly logger: WinstonLoggerService
@@ -208,6 +214,19 @@ export class MarketplaceStockProposalService {
const feePercent = await this.economyService.getMembershipFeeContractPercent(coopname);
const signedByOffer = new Map((lines ?? []).map((l) => [l.offer_id, l]));
// Заказ из остатка фондируется из членского кошелька «Стола заказов».
// Сколько членских средств уже есть у пайщика — столько паевого НЕ
// конвертируем (замена непоставленного: высвобожденные отменой средства уже
// здесь → доплаты с паевого нет). Дефицит покрываем конвертацией по строкам.
const memberWallet = await this.userWalletRepo.findByWalletAndUsername(
coopname,
'w.mkt.member',
member_account
);
let memberUnits = memberWallet?.available
? this.economyService.assetToUnits(memberWallet.available)
: 0n;
// Построчно: успехи сохраняем, на первом фейле останавливаемся и
// компенсируем уже созданные заказы (атомарность докладки — UX-инвариант:
// пайщик принимает её целиком).
@@ -215,7 +234,8 @@ export class MarketplaceStockProposalService {
try {
for (const item of proposal.items) {
// Заявление о конвертации — обязательный спутник каждой строки
// (контракт требует подписанный документ и публикует его в реестр).
// (пайщик подписывает на полную сумму строки; реально конвертируется
// только дефицит сверх уже внесённых членских средств — может быть 0).
const signedLine = signedByOffer.get(item.offer_id);
if (!signedLine) {
throw new BadRequestException(
@@ -237,9 +257,23 @@ export class MarketplaceStockProposalService {
'Заявление о конвертации не соответствует строке предложения — обновите принятие.'
);
}
const convert_statement = new SignedDigitalDocumentInputDTO(
signedLine.signed_statement
).toDocument() as MarketContract.Actions.Convert.IConvert['convert_statement'];
// Дефицит строки: списываем покрытие из уже внесённых членских средств,
// остаток добираем конвертацией паевого (Заявление подписано на полную
// сумму — конвертируем не больше дефицита).
const lineUnits = this.economyService.lineUnits(item.unit_price, item.quantity, feePercent);
const fromMember = memberUnits >= lineUnits ? lineUnits : memberUnits;
memberUnits -= fromMember;
const convertUnits = lineUnits - fromMember;
const convert_statement =
convertUnits > 0n
? (new SignedDigitalDocumentInputDTO(
signedLine.signed_statement
).toDocument() as MarketContract.Actions.Convert.IConvert['convert_statement'])
: null;
const convert_amount =
convertUnits > 0n ? this.economyService.unitsToAsset(convertUnits) : null;
const { order } = await this.stockService.createStockOrder({
coopname,
@@ -248,10 +282,10 @@ export class MarketplaceStockProposalService {
quantity: item.quantity,
checkout_id: proposal.id,
order_hash: signedLine.order_hash,
// Заказ из остатка из членских: паевой конвертируется на сумму строки
// (тело + взнос) отдельным действием перед заказом.
// Конвертация паевого только на дефицит сверх членских средств; при
// замене непоставленного дефицит 0 → заказ из членских без доплаты.
convert_statement,
convert_amount: expectedAmount,
convert_amount,
});
order_ids.push(order.id);
}