Bariki cashback, order edit, and current work in progress

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:57:03 +04:00
1 parent dda917fc71
commit 45477fb018
94 files changed
+10955 -650

No files matched your search

+31 -6
View File
@@ -25,7 +25,9 @@ import { getServiceByCode, type ParsedService } from './catalog.ts';
import { getMembership } from './clients.ts';
import { DomainError, type Ctx, type DomainEvent } from './context.ts';
import { getSettings } from './settings.ts';
import { recordPartnerEarning } from './servicePartners.ts';
import { findPromo } from './promo.ts';
import { accrueForOrder, onOrderCancelled, redeemPoints, takePointsEvents } from './points.ts';
export async function quoteForClient(ctx: Ctx, clientId: string, serviceCode: string, rawParams: Record<string, unknown>, promoCode?: string | null) {
const service = await getServiceByCode(ctx.db, serviceCode, { activeOnly: true });
@@ -85,6 +87,8 @@ export interface CreateOrderInput {
allowBusySlot?: boolean;
/** промокод на скидку (применяется, если выгоднее скидки подписки) */
promoCode?: string | null;
/** оплатить часть бариками: сумма (не больше N% и баланса); с промокодом нельзя */
usePoints?: number;
}
/** ORD-5: подтверждение заказа → «Новый», чек-лист из шаблона, запись в историю */
@@ -94,6 +98,8 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
const { service, params, quote: q, promo } = await quoteForClient(ctx, input.clientId, input.serviceCode, input.params, input.promoCode);
if (!service.isOnline && (!input.addressText?.trim() || !input.districtId)) throw new DomainError('ADDRESS_REQUIRED');
const flow = getFlow(service.flow);
if (input.usePoints && promo) throw new DomainError('POINTS_WITH_PROMO');
const pointEvents: DomainEvent[] = [];
const order = await ctx.db.$transaction(
async (tx) => {
@@ -134,11 +140,17 @@ export async function createOrder(ctx: Ctx, input: CreateOrderInput) {
await tx.promoCodeUse.create({ data: { promoId: promo.id, clientId: input.clientId, orderId: created.id } });
await tx.promoCode.update({ where: { id: promo.id }, data: { usedCount: { increment: 1 } } });
}
if (input.usePoints && input.usePoints > 0) {
await redeemPoints(ctx, { clientId: input.clientId, amount: input.usePoints, orderId: created.id, orderRef: `#${created.number}`, orderAmount: q.totalPrice, channel: input.createdBy ? 'WEB' : 'CLIENT' }, { adminId: input.createdBy?.id ?? null, owner: true }, tx);
pointEvents.push(...takePointsEvents(tx));
return tx.order.update({ where: { id: created.id }, data: { pointsUsed: input.usePoints } });
}
return created;
},
{ isolationLevel: 'ReadCommitted' },
);
await ctx.notify.emit({ type: 'order.created', orderId: order.id });
for (const e of pointEvents) await ctx.notify.emit(e);
return order;
}
@@ -150,7 +162,7 @@ export async function guardContext(db: Db | Tx, orderId: string): Promise<GuardC
db.lessonPackage.findUnique({ where: { orderId } }),
]);
return {
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId)),
checklistComplete: items.every((i) => i.checkedAt && (!i.requiresPhoto || i.fileId || i.photoWaivedReason)),
estimateAccepted: estimate?.status === 'ACCEPTED',
lessonsTotal: lessons?.total,
lessonsCompleted: lessons?.completed,
@@ -211,12 +223,16 @@ export async function transitionOrder(ctx: Ctx, input: TransitionInput, txArg?:
comment: input.comment,
},
});
return { order: updated, from: order.status };
// барики: отмена возвращает списанные и отменяет начисление; выполненный и оплаченный заказ — кэшбек
if (input.to === 'CANCELLED') await onOrderCancelled(ctx, tx, order.id);
else if (flow.done.includes(input.to)) await accrueForOrder(ctx, tx, order.id);
return { order: updated, from: order.status, extra: takePointsEvents(tx) };
};
const result = txArg ? await run(txArg) : await ctx.db.$transaction(run);
const event: DomainEvent = { type: 'order.status', orderId: input.orderId, from: result.from, to: input.to };
if (txArg) return { ...result, event };
await ctx.notify.emit(event);
for (const e of result.extra) await ctx.notify.emit(e);
return { ...result, event };
}
@@ -304,6 +320,7 @@ export async function cancelByClient(ctx: Ctx, input: { orderId: string; clientI
return r;
});
await ctx.notify.emit(result.event);
for (const e of result.extra) await ctx.notify.emit(e);
return { order: result.order, fee: policy.fee };
}
@@ -426,18 +443,19 @@ export async function repeatDraft(db: Db, orderId: string, clientId: string) {
export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: 'ADMIN' | 'PROVIDER'; id: string }; paymentMethodId?: string }) {
const provider = new ManualPaymentProvider();
const settings = await getSettings(ctx.db);
return ctx.db.$transaction(async (tx) => {
const events: DomainEvent[] = [];
const res = await ctx.db.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: input.orderId }, include: { client: { include: { agency: true } } } });
if (!order) throw new DomainError('NOT_FOUND');
if (input.by.type === 'PROVIDER' && order.providerId !== input.by.id) throw new DomainError('NOT_FOUND');
if (order.paymentStatus === 'PAID' || order.status === 'CANCELLED') throw new DomainError('NOT_PAID_STATE');
const res = await provider.confirmPayment(
{ orderId: order.id, amount: order.totalPrice, currency: 'AMD', description: `#${order.number}` },
{ orderId: order.id, amount: order.totalPrice - order.pointsUsed, currency: 'AMD', description: `#${order.number}` },
{ markedBy: input.by.id },
);
const now = ctx.now();
await tx.payment.create({
data: { orderId: order.id, provider: res.provider, amount: order.totalPrice, status: 'PAID', markedById: input.by.id },
data: { orderId: order.id, provider: res.provider, amount: order.totalPrice - order.pointsUsed, status: 'PAID', markedById: input.by.id },
});
const updated = await tx.order.update({
where: { id: order.id },
@@ -450,13 +468,20 @@ export async function markPaid(ctx: Ctx, input: { orderId: string; by: { type: '
agencyId: agency.id,
orderId: order.id,
pct: agency.commissionPct,
amount: agencyCommission(order.totalPrice, agency.commissionPct),
amount: agencyCommission(order.totalPrice - order.pointsUsed, agency.commissionPct),
period: yerevanMonthKey(now),
},
});
}
// заказ выполнил сотрудник партнёра — фиксируем доход партнёра по его условиям
await recordPartnerEarning(tx, order, settings, now);
// уже выполнен — сразу кэшбек
await accrueForOrder(ctx, tx, order.id);
events.push(...takePointsEvents(tx));
return updated;
});
for (const e of events) await ctx.notify.emit(e);
return res;
}
export function isOrderActive(flowCode: Parameters<typeof isTerminal>[0], status: OrderStatus) {